An Iris application compiles to a single static binary with no runtime dependencies, which makes deployment refreshingly boring: build the binary, hand it its configuration through the environment, and put a health check behind whatever supervises it. This chapter covers the Iris-specific parts first (production server options, graceful shutdown, proxy headers, the builder’s health endpoint) and keeps the generic DevOps material (Docker, Kubernetes, systemd, CI) short and honest.
Background
Deployment, stripped of tooling, is three problems: get your program onto a machine, keep it running when it crashes or the machine reboots, and replace it with a newer version without dropping requests. Every technology in this chapter (Docker, Kubernetes, systemd, a reverse proxy) automates part of one of those three.
Go makes the first problem unusually small. A static binary is an executable that carries everything it needs: no interpreter, no separate runtime, no shared libraries expected on the target machine. Copying one file is the entire installation.
A container packages a process together with its complete filesystem (binary, certificates, configuration defaults) into an image, an immutable snapshot that runs identically on your laptop, a CI runner and a production host. That immutability is the point: “works on my machine” stops being an argument, because the machine ships with the program. Docker is the tool most people use to build and run images, and a Dockerfile is the recipe that builds one.
A reverse proxy is a server (nginx, Caddy, a cloud load balancer) that sits in front of your application and forwards requests to it. It typically terminates TLS (holds the certificates and decrypts traffic) and can balance load across several application instances while shielding them from the open internet. The cost is indirection: the application now sees the proxy’s address instead of the client’s, which is why forwarded headers exist, and why they must be explicitly trusted.
An orchestrator such as Kubernetes automates the “keep it running” half at fleet scale: it starts a declared number of container replicas, restarts the ones that fail health checks, spreads them across machines, and rolls out new image versions gradually. On a single host, systemd (the standard Linux service manager) does the same job with far less machinery: start the binary at boot, restart it on failure.
Two smaller terms round out the vocabulary. A signal (SIGTERM,
SIGINT) is the operating system’s way of asking a process to stop;
graceful shutdown means catching that request and finishing in-flight
work before exiting. Continuous integration (CI) is the habit of
building and testing every change on a clean machine automatically, so a
broken build is caught at push time rather than deploy time.
The good news running through this chapter: because an Iris application compiles to one static binary configured from the environment, every one of these targets (container, Kubernetes pod, systemd unit) reduces to “put the file there and hand it variables.”
Building for Production
Disable cgo and the build produces a fully static Linux binary that runs in any container base image, including an empty one:
CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags="-s -w" -o bin/server .CGO_ENABLED=0removes the libc dependency.-trimpathstrips your local filesystem paths from the binary.-ldflags="-s -w"drops the symbol table and DWARF debug data, shrinking the binary considerably. Stack traces still work.
You do not need to inject a version with -X flags. Go stamps VCS metadata
into every binary built inside a git checkout, and Iris reads it through
runtime/debug.ReadBuildInfo: the commit hash and commit time appear as
iris.BuildRevision and iris.BuildTime, are printed at startup (unless
DisableStartupLog is set), and are served by the builder’s /health
endpoint described later. Build from a checkout that includes .git and
the information travels with the binary for free.
Serving: Listen and Run
app.Listen(addr) is the short form for development and simple
deployments. It is exactly equivalent to calling app.Run with sensible
defaults:
app.Listen(":8080")
// is the same as:
app.Run(iris.ServerOptions{
Addr: ":8080",
Network: "tcp",
KeepAlive: 3 * time.Minute,
SocketSharding: true,
})For production you usually want app.Run(iris.ServerOptions{...})
directly, because that is where timeouts, TLS and shutdown behavior live.
Both calls block until the server stops and return nil after a graceful
shutdown. A failed start, or a server that later dies with an error, is
logged at fatal level and the process exits with status 1, so systemd or a
container runtime sees the failure and restarts the unit. That is why the
listings in this book call app.Listen and app.Run as statements. A
process that runs more than one Iris server, or a test that opens a real
listener, sets iris.Options{DisableFatalRun: true}; the failure is then
logged at error level and returned to the caller.
Server timeouts
A server without timeouts is one slow client away from resource
exhaustion. iris.ServerOptions.Timeout takes a *iris.TimeoutOptions,
built fluently with iris.Timeout():
package main
import (
"time"
"github.com/kataras/iris/v14"
)
func main() {
app := iris.New()
app.Get("/", func(ctx iris.Context) {
ctx.JSON(iris.Map{"status": "ok"})
})
app.Run(iris.ServerOptions{
Addr: ":8080",
Timeout: iris.Timeout().
WithServerTimeout(60 * time.Second).
WithShutdownOnInterruptTimeout(20 * time.Second),
})
}WithServerTimeout(total) treats the argument as the total lifecycle
budget and derives the read-header, write and idle timeouts from it. The
body-read timeout is deliberately not set globally, so handlers that
accept large uploads keep control. Every field can also be set
individually:
| Method | Field it sets |
|---|---|
WithServerTimeout(d) | ServerTimeout, derives header/write/idle |
WithReadTimeout(d) | ReadTimeout (headers and body) |
WithReadHeaderTimeout(d) | ReadHeaderTimeout |
WithWriteTimeout(d) | WriteTimeout |
WithIdleTimeout(d) | IdleTimeout |
WithHandlerTimeout(d, html...) | Wraps the router in http.TimeoutHandler |
WithShutdownOnInterruptTimeout(d) | Drain window on interrupt (default 15s) |
TLS
When Iris terminates TLS itself, ServerOptions offers two mutually
exclusive paths; combining them panics at startup, on purpose:
// Certificate files (or raw PEM contents in the same fields):
app.Run(iris.ServerOptions{
Addr: ":443",
CertFile: "/etc/ssl/certs/example.com.pem",
KeyFile: "/etc/ssl/private/example.com.key",
})
// Automatic certificates from Let's Encrypt:
app.Run(iris.ServerOptions{
Addr: ":443",
Hosts: []string{"example.com"},
Letsencrypt: true,
})Letsencrypt is a plain boolean. The first Hosts entry is the root
domain used for issuance; certificates are cached in a local
./letsencrypt directory and renewed automatically 14 days before
expiration. If CertFile/KeyFile are set alongside it, they act as a
fallback when issuance fails, which is convenient for local testing. A
custom TLSConfig *tls.Config field exists for full control.
In most production topologies, however, a reverse proxy or load balancer terminates TLS and Iris listens on plain HTTP behind it; see Running Behind a Reverse Proxy.
Tunnels: ngrok and SSH
For a demo, or to receive a webhook on a laptop, expose the local server
through a tunnel instead of deploying it. Ngrok opens an ngrok tunnel
and SSHTunnel a reverse SSH tunnel through serveo.net, localhost.run or
a host of your own. Use one of them alone: the tunnel terminates TLS, so
CertFile, KeyFile, Letsencrypt and TLSConfig stay unset.
ngrok is opt-in. Its code lives in the core/server/ngrok package, so
a program that never opens a tunnel does not link the ngrok SDK. Add the
blank import next to the options and the package registers itself:
import _ "github.com/kataras/iris/v14/core/server/ngrok"
app.Run(iris.ServerOptions{
Addr: ":8080",
Ngrok: &iris.NgrokOptions{
AuthToken: "your-ngrok-token", // or the NGROK_AUTHTOKEN variable
Region: "us",
},
})Without the import the server does not start. It fails with server: ngrok is configured but its listener is not linked in, followed by the
import line to add, rather than quietly serving without a tunnel. The
options themselves did not change, so a YAML file that sets Ngrok
keeps working once the import is there.
SSHTunnelOptions lives in the core/server package and has no
root-package alias. It runs
ssh -T -R <RemotePort>:<LocalHost>:<LocalPort> [user@]<Domain>
for you:
import "github.com/kataras/iris/v14/core/server"
app.Run(iris.ServerOptions{
Addr: ":8080",
SSHTunnel: &server.SSHTunnelOptions{
Domain: "serveo.net",
RemotePort: 80,
LocalHost: "localhost",
LocalPort: 8080,
},
})Graceful Shutdown
Iris ships with graceful shutdown enabled. Unless you set
ServerOptions.DisableInterruptHandler, the server installs a handler for
SIGINT and SIGTERM that stops accepting new connections, drains
in-flight requests for up to ShutdownOnInterruptTimeout (15 seconds by
default), and then exits. docker stop, a Kubernetes pod termination and
a plain Ctrl+C all take this path with zero code on your part.
Cleanup work is a separate concern. app.Shutdown(ctx) runs every
registered closer: the server’s own shutdown is prepended automatically
by Run, followed by everything you registered through the builder’s
Deferrables(...) step, app.AddCloser(...), or Close() methods that
Services(...) auto-detected (Chapter 3). To run
those closers on interrupt as well, register the shutdown yourself with
iris.RegisterOnInterrupt:
package main
import (
"context"
"time"
"github.com/kataras/iris/v14"
)
func main() {
app := newApp() // your iris.NewBuilder(...).Build() chain.
iris.RegisterOnInterrupt(func() {
ctx, cancel := context.WithTimeout(
context.Background(), 10*time.Second)
defer cancel()
app.Shutdown(ctx)
})
app.Listen(":8080")
}RegisterOnInterrupt accepts the same forms as AddCloser: func(),
func() error, io.Closer, and context-aware variants. Errors from
individual closers are aggregated with errors.Join, so errors.Is
still matches each underlying error.
The reason that registration matters is worth seeing in full. There are two
separate lists, and a signal drains only one of them before calling
os.Exit. The server’s own entry on that list shuts down the HTTP server
and nothing else, so without a registration of your own, your Deferrables
and your services’ Close methods never run:
Containerizing with Docker
A multi-stage build keeps the shipped image small: the first stage uses the full Go toolchain image to compile the binary, and the final stage copies only that binary into a minimal base. The compilers, caches and source code never reach production.
A static Go binary wants a minimal image. The multi-stage build below
compiles in the official Go image and ships on distroless/static, which
adds CA root certificates and a non-root user on top of nothing at all,
the two things scratch lacks. If you prefer scratch, copy
/etc/ssl/certs/ca-certificates.crt from the build stage yourself.
# syntax=docker/dockerfile:1
FROM golang:1.26 AS build
WORKDIR /src
# Cache module downloads separately from source changes.
COPY go.mod go.sum ./
RUN go mod download
# Copy everything, including .git, so the binary carries
# its VCS revision and build time (startup log and /health).
COPY . .
RUN CGO_ENABLED=0 GOOS=linux go build -trimpath \
-ldflags="-s -w" -o /out/bookshelf .
FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/bookshelf /bookshelf
EXPOSE 8080
ENTRYPOINT ["/bookshelf"]The exec-form ENTRYPOINT makes the binary PID 1, so it receives the
SIGTERM that docker stop sends and drains gracefully as described
above.
Configuration enters through the environment: the whole YAML document in one variable, following the pattern from the Bookshelf tutorial and Chapter 11:
docker build -t bookshelf .
docker run --rm -p 8080:8080 \
-e ADMIN_PASSWORD=change-me \
-e BOOKSHELF_CONFIG='
LogLevel: info
Addr: ":8080"
Env: production
LogRequests: false
' bookshelfOne container-specific note: the builder’s LogRequests(true) step writes
access_log.json to the working directory in addition to stdout. In a
container (especially one with a read-only root filesystem), pass a
stdout-only access log through LogRequestsWith instead:
import (
"os"
"github.com/kataras/iris/v14/middleware/accesslog"
)
ac := accesslog.New(os.Stdout)
ac.SetFormatter(&accesslog.JSON{})
// In the builder chain, in place of LogRequests(true):
// .LogRequestsWith(ac).Running Behind a Reverse Proxy
The most common production topology puts nginx, Caddy or a cloud load balancer in front of Iris: the proxy terminates TLS on 443 and forwards plain HTTP to the application on a local port. Two things must then be configured: the proxy must forward the client’s identity in headers, and Iris must be told to trust those headers.
The proxy side, labeled clearly as nginx configuration (not Go):
# /etc/nginx/conf.d/bookshelf.conf — nginx configuration
server {
listen 443 ssl;
server_name example.com;
ssl_certificate /etc/ssl/certs/example.com.pem;
ssl_certificate_key /etc/ssl/private/example.com.key;
location / {
proxy_pass http://127.0.0.1:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-Ip $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}The same with Caddy, which obtains certificates automatically and sets the
X-Forwarded-* headers by default (Caddyfile):
example.com {
reverse_proxy 127.0.0.1:8080
}On the Iris side, proxy trust lives in iris.ContextOptions. By default
no X- headers are trusted. A client could otherwise spoof its IP or
claim HTTPS. Opt in only when a proxy you control sets them:
app := iris.New(iris.Defaults(func(opts *iris.Options) {
// ctx.RemoteAddr() resolves the client IP from these headers.
opts.Context.RemoteAddrHeaders = []string{
"X-Forwarded-For", "X-Real-Ip",
}
// ctx.IsSSL() reports true when this header matches.
opts.Context.SSLProxyHeaders = map[string]string{
"X-Forwarded-Proto": "https",
}
// ctx.Host() may read the original host from this header.
opts.Context.HostProxyHeaders = map[string]bool{
"X-Forwarded-Host": true,
}
}))With this in place, ctx.RemoteAddr() returns the real client address
instead of the proxy’s, ctx.IsSSL() reflects the original scheme, and
rate limiting, logging and audit trails stay meaningful. The same fields
are settable from YAML configuration under the Context: key
(Chapter 11).
Health Checks and Kubernetes
The builder registers a health endpoint as one of its compiler-enforced steps (Chapter 3):
iris.NewBuilder(cfg.Options).
Prefix("/api").
// ...
Health(true, cfg.Env, cfg.Developer).
// ...Health(true, env, developer) registers GET /health at the application
root, deliberately outside the Prefix, because load balancers and
orchestrators probe fixed paths. The response is plain text with status
200: the server name and environment, the VCS build revision and time
stamped into the binary, the developer name, cloud-container detection
when applicable, and a final OK line. Probes only care about the status
code; humans get the build information as a bonus. Applications built
without the builder can register the same handler manually from
github.com/kataras/iris/v14/middleware/modrevision.
Kubernetes distinguishes two probes. The liveness probe asks “is this
process healthy enough to keep running?” Repeated failures make the
orchestrator restart the container. The readiness probe asks “should
traffic be routed here right now?” Failures merely take the pod out of
the load balancer until it recovers. Pointing both at /health is a
sensible default for a stateless API.
Wiring Kubernetes probes to it is a few lines. A minimal but complete deployment (Kubernetes configuration):
# deploy/bookshelf.yaml — Kubernetes configuration
apiVersion: apps/v1
kind: Deployment
metadata:
name: bookshelf
spec:
replicas: 2
selector:
matchLabels:
app: bookshelf
template:
metadata:
labels:
app: bookshelf
spec:
containers:
- name: bookshelf
image: registry.example.com/bookshelf:1.4.0
ports:
- containerPort: 8080
env:
- name: BOOKSHELF_CONFIG
valueFrom:
configMapKeyRef:
name: bookshelf-config
key: config.yml
- name: ADMIN_PASSWORD
valueFrom:
secretKeyRef:
name: bookshelf-secrets
key: admin-password
livenessProbe:
httpGet:
path: /health
port: 8080
readinessProbe:
httpGet:
path: /health
port: 8080
periodSeconds: 5
---
apiVersion: v1
kind: Service
metadata:
name: bookshelf
spec:
selector:
app: bookshelf
ports:
- port: 80
targetPort: 8080On pod termination Kubernetes sends SIGTERM, which triggers the graceful
drain from earlier. Keep the pod’s terminationGracePeriodSeconds (30 by
default) above your ShutdownOnInterruptTimeout so draining is never cut
short.
Environment Configuration
The pattern that ties every deployment target together (Docker, systemd,
Kubernetes) is a single YAML document delivered through a single
environment variable, with secrets in their own variables. Iris supports
it natively: (*iris.Options).Bind reads the SERVER_CONFIG variable
first and falls back to a file, so one container variable can carry the
entire framework configuration while a committed file serves development:
var opts iris.Options
if err := opts.Bind("config.yml"); err != nil {
log.Fatal(err)
}
app := iris.New(opts)Applications with settings of their own embed iris.Options inline in a
custom Config struct and read their own variable, exactly as the
Bookshelf tutorial does with BOOKSHELF_CONFIG and ADMIN_PASSWORD.
Partial documents are safe: options passed to iris.New merge with the
defaults, so the YAML only needs to state what differs.
Chapter 11 covers the full pattern,
including multi-environment layouts.
Running Under systemd
On a plain Linux host, systemd is all the process supervision an Iris binary needs (systemd unit):
# /etc/systemd/system/bookshelf.service — systemd unit
[Unit]
Description=Bookshelf API
After=network-online.target
Wants=network-online.target
[Service]
User=bookshelf
Group=bookshelf
WorkingDirectory=/opt/bookshelf
ExecStart=/opt/bookshelf/bookshelf
EnvironmentFile=/etc/bookshelf/env
Restart=on-failure
TimeoutStopSec=20
[Install]
WantedBy=multi-user.targetThe EnvironmentFile holds BOOKSHELF_CONFIG=... and the secret
variables, readable only by root and the service user. systemctl stop
sends SIGTERM, so the graceful drain applies here too; TimeoutStopSec
is set above the default 15-second drain window for the same reason as
the Kubernetes grace period.
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelfContinuous Integration
CI for an Iris service is ordinary Go CI: vet, test with the race
detector, and scan dependencies. The suite from
Chapter 16 runs entirely in-process via httptest, so
no services or containers are needed for the tests themselves
(GitHub Actions workflow):
# .github/workflows/ci.yml — GitHub Actions
name: CI
on:
push:
branches: [main]
pull_request:
jobs:
test:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v6
- uses: actions/setup-go@v6
with:
go-version: 1.26.x
- run: go vet ./...
- run: go test -race ./...
- run: go run golang.org/x/vuln/cmd/govulncheck@latest ./...Add the docker build/push step of your registry of choice after the
test job; the Dockerfile from this chapter needs no CI-specific changes.
Summary
- Build with
CGO_ENABLED=0 go build -trimpath -ldflags="-s -w"for a static binary; Go’s VCS stamping gives/healthand the startup log their revision and build time with no-Xflags. app.Listen(addr)is shorthand forapp.Run(iris.ServerOptions{...}); production servers setTimeoutviairis.Timeout()and choose betweenCertFile/KeyFile,Letsencrypt: true, or a TLS-terminating proxy in front. A failed start logs and exits with status 1;iris.Options.DisableFatalRunreturns the error instead.- Graceful shutdown is on by default:
SIGINT/SIGTERMdrains in-flight requests withinShutdownOnInterruptTimeout;app.Shutdown(ctx)runs closers andDeferrables, andiris.RegisterOnInterrupthooks them into the interrupt path. - Behind a proxy, set
Context.RemoteAddrHeaders,SSLProxyHeadersandHostProxyHeaders; nothing is trusted by default. - The builder’s
Health(true, env, developer)step servesGET /healthat the root; point liveness and readiness probes at it. - One YAML document in one environment variable configures everything;
Options.Bindand the embedded-options pattern from Chapter 11 implement it.
Further Reading
- Docker: Get started: the official introduction to images, containers and Dockerfiles.
- Kubernetes: Overview: what an orchestrator is and which problems Kubernetes actually solves.
- Kubernetes: Liveness, Readiness and Startup Probes:
configuring the probes this chapter points at
/health. - MDN: X-Forwarded-For: the forwarded-client-address header and its trust caveats.
- Let’s Encrypt: How It Works.
The automated certificate issuance behind
Letsencrypt: true.