Files
steve e8913943f9 p5-07: reference deployment (server-only compose + reverse-proxy docs)
The reverse proxy is assumed to live outside this project (Caddy,
nginx, Traefik, whatever the operator already runs). The reference
compose stands up only the server: image-pinned via RM_VERSION,
named volume for operator state, localhost-bound so the proxy
reaches it on loopback.

docs/reverse-proxy.md covers what the proxy must forward — the
X-Forwarded-* headers, Host, and Connection: upgrade for the agent
WebSocket and live-log streams — plus the RM_TRUSTED_PROXY CIDR
rule that gates header trust. Worked examples for Caddy, nginx
(with the websocket upgrade map + 1h proxy_read_timeout for live
logs), and Traefik.
2026-05-05 17:15:00 +01:00

4.4 KiB

Running behind a reverse proxy

The restic-manager server is HTTP-only by design (see spec.md §11): TLS termination, public hostname, ACME, HSTS, and edge-level rate limiting all belong to a reverse proxy that you already operate outside this project. The reference compose in deploy/docker-compose.yml stands up only the server; this page covers what your proxy needs to do to make the rest of it work.

What the proxy must forward

The server reads four headers when (and only when) the immediate peer matches RM_TRUSTED_PROXY:

Header Value Why
X-Forwarded-For The original client IP (single value, or comma chain) Rate-limit keys, audit log entries, and OIDC redirect-URI checks all use the real client IP.
X-Forwarded-Proto https The server emits absolute URLs (e.g. OIDC redirect URIs) using this.
Host The public hostname clients use Cookies are scoped to this; RM_BASE_URL must match.
Connection/Upgrade Pass through unchanged The agent connects on /ws/agent and the live-log viewer connects on /api/jobs/{id}/stream — both are WebSockets and need Upgrade: websocket to survive the hop.

Set RM_TRUSTED_PROXY to the CIDR (or comma-separated list of CIDRs) the proxy connects from. Anything outside that range has its X-Forwarded-* headers ignored, so a stray request that bypasses the proxy can't spoof the client IP.

Example: Caddy

restic.example.com {
    # Caddy's default reverse_proxy preserves Host, sets
    # X-Forwarded-For/Proto, and passes Connection: upgrade through,
    # so a single directive covers HTTP + WebSocket.
    reverse_proxy 127.0.0.1:8080

    encode zstd gzip
}

RM_TRUSTED_PROXY=127.0.0.1/32 if Caddy and the server share the host; the docker-bridge CIDR (commonly 172.16.0.0/12) if Caddy runs in another container on the default bridge network.

Example: nginx

server {
    listen 443 ssl http2;
    server_name restic.example.com;

    ssl_certificate     /etc/ssl/restic.example.com.fullchain.pem;
    ssl_certificate_key /etc/ssl/restic.example.com.key.pem;

    location / {
        proxy_pass         http://127.0.0.1:8080;
        proxy_http_version 1.1;

        # WebSocket support — agent + live-log endpoints need this.
        proxy_set_header   Upgrade           $http_upgrade;
        proxy_set_header   Connection        $connection_upgrade;

        # Trusted-proxy headers.
        proxy_set_header   Host              $host;
        proxy_set_header   X-Forwarded-For   $proxy_add_x_forwarded_for;
        proxy_set_header   X-Forwarded-Proto https;

        # Live job logs are long-running streams. Bump read timeouts
        # so nginx doesn't drop them mid-backup.
        proxy_read_timeout 1h;
        proxy_send_timeout 1h;
    }
}

# Standard websocket upgrade map (define once at the http {} level).
map $http_upgrade $connection_upgrade {
    default upgrade;
    ''      close;
}

RM_TRUSTED_PROXY for the same-host case: 127.0.0.1/32.

Example: Traefik (label-based)

labels:
  - "traefik.enable=true"
  - "traefik.http.routers.restic-manager.rule=Host(`restic.example.com`)"
  - "traefik.http.routers.restic-manager.entrypoints=websecure"
  - "traefik.http.routers.restic-manager.tls.certresolver=letsencrypt"
  - "traefik.http.services.restic-manager.loadbalancer.server.port=8080"

Traefik handles X-Forwarded-* and Connection: upgrade by default. RM_TRUSTED_PROXY should be the docker network the Traefik container shares with the server (commonly 172.16.0.0/12 for the default bridge, or whatever your overlay network's CIDR is).

Sanity-checking the wiring

After bringing the stack up:

  1. curl -fsS https://restic.example.com/healthz — should return 200.
  2. The login page should report HTTPS in the address bar; cookies set after login should carry the Secure flag.
  3. Check the server log for the config resolved line: trusted_proxies must include the IP/CIDR your proxy actually connects from.
  4. Enrol a test agent — the WebSocket handshake hitting /ws/agent confirms Upgrade is being forwarded correctly.

If any of those fail, the proxy is the first place to look — the server itself is intentionally minimal.