Chapter 19

Deployment & DevOps

One static binary. Graceful shutdown, Docker, running behind a reverse proxy, Kubernetes health checks, systemd units and continuous integration.

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:

Shell
CGO_ENABLED=0 GOOS=linux go build -trimpath \
    -ldflags="-s -w" -o bin/server .
  • CGO_ENABLED=0 removes the libc dependency.
  • -trimpath strips 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:

Go
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():

Go
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:

MethodField 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:

Go
// 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:

Go
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:

Go
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:

Go
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:

What a SIGTERM actually runs A signal drains the interrupt list and then calls os.Exit, and the server's own entry shuts down the HTTP server only, so your Deferrables and Close methods run only if something on that list calls app.Shutdown. SIGINT or SIGTERM docker stop, kubectl delete pod, systemctl stop, Ctrl+C signal.Notify(os.Interrupt, SIGINT, SIGTERM) installed unless ServerOptions.DisableInterruptHandler THE INTERRUPT LIST, PROCESS-GLOBAL THE APPLICATION CLOSER LIST 1. whatever you registered iris.RegisterOnInterrupt(func() { app.Shutdown(ctx) }) 2. accesslog Close registered by accesslog.New itself 3. srv.Shutdown(ctx) added when the listener starts, with ShutdownOnInterruptTimeout (15s) 1. srv.Shutdown prepended by app.Run 2. Deferrables(...) and AddCloser(...) in registration order 3. Close() values from Services(...) auto-detected the only path os.Exit(0) nothing after this runs the drain itself stop accepting new connections, let in-flight requests finish, close the listener, exit. Closer errors are joined with errors.Join, so errors.Is still matches each one. SET THE OUTER TIMEOUTS ABOVE THE INNER ONE, OR THE DRAIN IS CUT SHORT ShutdownOnInterruptTimeout 15s (default) must be under systemd TimeoutStopSec 20s must be under Kubernetes terminationGracePeriodSeconds 30s (default). Without an iris.RegisterOnInterrupt registration, a signal drains requests and exits: the server's entry shuts down the HTTP server only, so Deferrables and the Close methods found by Services never run.
A signal drains the interrupt list and then calls os.Exit, and the server's own entry shuts down the HTTP server only, so your Deferrables and Close methods run only if something on that list calls app.Shutdown.

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.

Dockerfile
# 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:

Shell
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
' bookshelf

One 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:

Go
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):

nginx
# /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:

Go
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):

Go
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):

YAML
# 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: 8080

On 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:

Go
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):

INI
# /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.target

The 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.

Shell
sudo systemctl daemon-reload
sudo systemctl enable --now bookshelf

Continuous 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):

YAML
# .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 /health and the startup log their revision and build time with no -X flags.
  • app.Listen(addr) is shorthand for app.Run(iris.ServerOptions{...}); production servers set Timeout via iris.Timeout() and choose between CertFile/KeyFile, Letsencrypt: true, or a TLS-terminating proxy in front. A failed start logs and exits with status 1; iris.Options.DisableFatalRun returns the error instead.
  • Graceful shutdown is on by default: SIGINT/SIGTERM drains in-flight requests within ShutdownOnInterruptTimeout; app.Shutdown(ctx) runs closers and Deferrables, and iris.RegisterOnInterrupt hooks them into the interrupt path.
  • Behind a proxy, set Context.RemoteAddrHeaders, SSLProxyHeaders and HostProxyHeaders; nothing is trusted by default.
  • The builder’s Health(true, env, developer) step serves GET /health at the root; point liveness and readiness probes at it.
  • One YAML document in one environment variable configures everything; Options.Bind and the embedded-options pattern from Chapter 11 implement it.

Further Reading

Twenty-five chapters, about 85,000 words. Try BindJSON, graceful shutdown or error map.