<!--
  Deployment & DevOps
  From the Iris book, chapter 19, covering Iris 14.0.0 on Go 1.27.
  Import path: github.com/kataras/iris/v14
  Canonical page: https://iris-go.com/docs/deployment-devops/
-->
# Chapter 19: Deployment & DevOps

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:

```sh
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:

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

```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](#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](https://iris-go.com/docs/iris-newbuilder/)). 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:

\[Diagram: 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](https://github.com/kataras/iris/blob/main/TUTORIAL.md)
and [Chapter 11](https://iris-go.com/docs/configuration-management/):

```sh
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](https://iris-go.com/docs/configuration-management/)).

## Health Checks and Kubernetes

The builder registers a health endpoint as one of its compiler-enforced
steps ([Chapter 3](https://iris-go.com/docs/iris-newbuilder/)):

```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](https://iris-go.com/docs/configuration-management/) 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.

```sh
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](https://iris-go.com/docs/testing/) 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](https://iris-go.com/docs/configuration-management/) implement it.

## Further Reading

- [Docker: Get started](https://docs.docker.com/get-started/): the
  official introduction to images, containers and Dockerfiles.
- [Kubernetes: Overview](https://kubernetes.io/docs/concepts/overview/):
  what an orchestrator is and which problems Kubernetes actually solves.
- [Kubernetes: Liveness, Readiness and Startup Probes](https://kubernetes.io/docs/tasks/configure-pod-container/configure-liveness-readiness-startup-probes/):
  configuring the probes this chapter points at `/health`.
- [MDN: X-Forwarded-For](https://developer.mozilla.org/en-US/docs/Web/HTTP/Headers/X-Forwarded-For):
  the forwarded-client-address header and its trust caveats.
- [Let's Encrypt: How It Works](https://letsencrypt.org/how-it-works/).
  The automated certificate issuance behind `Letsencrypt: true`.
