Docs › Running behind a reverse proxy

Running behind a reverse proxy

This page is for putting SpatiumDDI behind your own reverse proxy: an nginx, Caddy, Traefik, HAProxy or Nginx Proxy Manager host that terminates TLS, or a Kubernetes Ingress. It applies to Docker Compose and to the Helm chart. (The OS appliance serves its own HTTPS and needs none of this.)

Done right it is three settings and four headers. Done wrong it fails in confusing ways (see the troubleshooting table): a redirect loop, a 400 on every call, SSO “redirect_uri mismatch”, being signed out on every reload, or 429 Too many login attempts for the whole organisation.

1. Supported topology

browser ──https──> your proxy ──http──> SpatiumDDI frontend (nginx) ──> api
                  (terminates TLS)      Compose: host:8077
                                        Helm: frontend Service, port 80

2. Required settings

Setting Value Why
Settings > General > External URL the public URL, https://ddi.example.com Mandatory for OIDC and SAML (callback / ACS URLs are built from it), recommended always. Empty means the URL is rebuilt from each request; the api logs sso_external_url_not_set when it falls back
FRONTEND_TRUSTED_PROXY_CIDRS (Helm: frontend.trustedProxyCidrs) the proxy’s address, and nothing else Lets the real client IP and scheme through, so the login throttle, audit log, last_login_ip, the Web UI allow-list and ACME source checks see the user, not the proxy, and the api sees https
TRUSTED_HOSTS leave unset, or list the exact Host the proxy forwards A proxy that rewrites Host to its upstream name gets 400 on every call; the api logs trusted_hosts_refused_host

FRONTEND_TRUSTED_PROXY_CIDRS

The frontend nginx believes X-Forwarded-For and X-Forwarded-Proto only from a TCP peer in this list (comma-separated addresses or CIDRs). It walks X-Forwarded-For from the right, skipping listed hops, and takes the first address that is not listed, so an address a client puts at the front of the header is never reached. It then hands the api that address in X-Real-IP, which it always writes itself and never passes through. Unset or empty means no proxy is trusted, which is exactly the behaviour of an install with no proxy. An entry that is not an address or CIDR stops the frontend container at start, with the entry named in its log.

# .env (Docker Compose). Proxy at 192.168.10.5:
FRONTEND_TRUSTED_PROXY_CIDRS=192.168.10.5/32

3. Headers the proxy must send

Header Value
Host the public hostname, preserved (not the upstream’s name)
X-Forwarded-Proto https
X-Forwarded-For the client address (append, do not replace)
X-Real-IP the client address (optional; nginx uses X-Forwarded-For)

Most proxies (Caddy, Traefik, HAProxy with option forwardfor, Nginx Proxy Manager) send these themselves. Plain nginx needs them written out.

4. Streaming

Three features are long-lived fetch streams (server-sent events): the AI chat (/api/v1/ai/chat), the cluster-health stream (/api/v1/appliance/cluster/health/stream) and the container-log follow streams (/api/v1/appliance/containers/.../logs/stream; the nmap scan progress stream behaves the same). The frontend nginx already serves them unbuffered with a one hour timeout. Your proxy must too: response buffering off and a read timeout of at least an hour. No websocket upgrade is needed. A buffering or short-timeout proxy makes these features look broken (a chat that hangs, a log view that stays empty), while everything else works.

5. Examples

Replace ddi.example.com, the certificate paths and docker-host with yours. docker-host:8077 is the Compose frontend (HTTP_PORT).

nginx

server {
  listen 443 ssl;
  server_name ddi.example.com;
  ssl_certificate     /etc/ssl/ddi.example.com/fullchain.pem;
  ssl_certificate_key /etc/ssl/ddi.example.com/privkey.pem;

  location / {
    proxy_pass http://docker-host:8077;
    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 https;
    proxy_http_version 1.1;
    proxy_buffering off;
    proxy_read_timeout 3600s;
    proxy_send_timeout 3600s;
    client_max_body_size 4g;   # backup restore and slot-image uploads
  }
}

Add proxy_set_header Connection ""; if you serve other sites from the same upstream block with keepalive.

Caddy

ddi.example.com {
    reverse_proxy docker-host:8077 {
        flush_interval -1
    }
}

Caddy sets Host, X-Forwarded-For and X-Forwarded-Proto itself, preserves Host, and has no read timeout on a response stream by default.

Traefik (Docker labels)

services:
  traefik:
    image: traefik:v3.6
    command:
      - --providers.docker=true
      - --providers.docker.exposedbydefault=false
      - --entrypoints.websecure.address=:443
      - --entrypoints.websecure.transport.respondingTimeouts.writeTimeout=0s
      - --entrypoints.websecure.transport.respondingTimeouts.idleTimeout=3600s
      - --certificatesresolvers.le.acme.tlschallenge=true
      - [email protected]
      - --certificatesresolvers.le.acme.storage=/letsencrypt/acme.json
    ports: ["443:443"]
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - ./letsencrypt:/letsencrypt
    networks: [proxy]

  frontend:   # in SpatiumDDI's docker-compose.override.yml
    networks: [default, proxy]
    labels:
      - traefik.enable=true
      - traefik.docker.network=proxy
      - traefik.http.routers.ddi.rule=Host(`ddi.example.com`)
      - traefik.http.routers.ddi.entrypoints=websecure
      - traefik.http.routers.ddi.tls.certresolver=le
      - traefik.http.services.ddi.loadbalancer.server.port=80
      - traefik.http.services.ddi.loadbalancer.responseforwarding.flushinterval=-1ms

networks:
  proxy:
    external: true   # docker network create proxy

writeTimeout defaults to unlimited, so the respondingTimeouts lines are there to make that explicit and to keep idle streams open; the load-bearing part is flushinterval=-1ms (flush every write immediately). Traefik passes Host through and sets the X-Forwarded-* headers itself. Use Traefik 3.6 or later: against a current Docker Desktop, 3.5 built no routers from these labels, so every request answered 404 page not found. traefik.docker.network matters because the frontend now sits on two networks: without it Traefik may pick the address on SpatiumDDI’s own network, which it cannot reach, and answer 504/502. Set FRONTEND_TRUSTED_PROXY_CIDRS to Traefik’s address on the proxy network (docker network inspect proxy shows it, or use that network’s subnet if nothing else joins it). Mounting the Docker socket gives Traefik control of the host; use a socket proxy if that matters to you.

HAProxy

defaults
    mode http
    timeout connect 5s
    timeout client 1h
    timeout server 1h

frontend https_in
    bind :443 ssl crt /etc/haproxy/certs/ddi.example.com.pem
    option forwardfor
    http-request set-header X-Forwarded-Proto https
    http-request set-header X-Real-IP %[src]
    default_backend ddi

backend ddi
    server frontend docker-host:8077

timeout client and timeout server must be at least as long as the streams you want (1h); HAProxy’s own default is to have none, and it warns at start. HAProxy does not buffer responses and preserves Host. option forwardfor appends a second X-Forwarded-For header line rather than editing the client’s; the frontend nginx reads them together, so a value a client sends is still never believed.

Nginx Proxy Manager

This one is written from NPM’s documentation and has not been run against SpatiumDDI the way the four examples above were; NPM generates an nginx location like the one under nginx, so the same settings apply.

On the proxy host (Details tab): scheme http, forward host docker-host, port 8077; leave Websockets Support off (not needed). SSL tab: pick the certificate, enable Force SSL and HTTP/2 Support. NPM sends Host, X-Forwarded-For, X-Forwarded-Proto and X-Real-IP itself. On the Advanced tab paste:

proxy_buffering off;
proxy_read_timeout 3600s;
proxy_send_timeout 3600s;
client_max_body_size 4g;

To limit this to the streams instead, add custom locations for /api/v1/ai/chat, /api/v1/appliance/cluster/health/stream and /api/v1/appliance/containers with the same forward target, and put the snippet in each location’s gear icon. NPM’s own container must be in a network that can reach the frontend, and its address on that network goes in FRONTEND_TRUSTED_PROXY_CIDRS.

Kubernetes Ingress

See Helm and Kubernetes below and KUBERNETES.md.

Helm and Kubernetes

# values.yaml
frontend:
  trustedProxyCidrs: "10.42.0.0/16"   # the ingress controller's pod network
ingress:
  enabled: true
  className: nginx
  hosts:
    - host: ddi.example.com
      paths:
        - { path: /, pathType: Prefix }
  tls:
    - secretName: ddi-tls
      hosts: [ddi.example.com]
  annotations:
    nginx.ingress.kubernetes.io/proxy-buffering: "off"
    nginx.ingress.kubernetes.io/proxy-read-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "3600"
    nginx.ingress.kubernetes.io/proxy-body-size: "4g"
    nginx.ingress.kubernetes.io/proxy-request-buffering: "off"

Troubleshooting

Symptom Cause Fix
Redirect loop (ERR_TOO_MANY_REDIRECTS) Helm frontend.tls.enabled=true behind a TLS-terminating proxy that talks http to port 80, with no trusted proxy Set frontend.trustedProxyCidrs to the proxy and send X-Forwarded-Proto: https, or set frontend.tls.enabled=false
400 Invalid host header on every call TRUSTED_HOSTS is set and the proxy forwards a different Host (nginx proxy_pass without proxy_set_header Host) Preserve Host, or add the name the proxy sends to TRUSTED_HOSTS, or unset it. The api log names the refused host
SSO redirect_uri mismatch; SAML Destination error or saml_requires_https External URL is empty, so the callback is built as http:// from the request Set Settings > General > External URL to the public https:// URL
Signed out on every reload The refresh cookie is Secure but the browser is on an http:// page, or the proxy is not sending X-Forwarded-Proto: https from a trusted address Serve the public side over https; check FRONTEND_TRUSTED_PROXY_CIDRS covers the proxy
429 Too many login attempts for everyone Every user arrives from one address: the proxy is not in FRONTEND_TRUSTED_PROXY_CIDRS, or (if you narrowed it) the api’s TRUSTED_PROXY_CIDRS no longer covers the frontend Set FRONTEND_TRUSTED_PROXY_CIDRS to the address the proxy connects from; check Audit Log source IPs show real clients
429 from /health/ready, /health/live, /health/startup or /metrics The frontend allows each client 5 requests a second there, with a burst of 20 (#2944); /health/platform is not limited. Behind a proxy that is not in FRONTEND_TRUSTED_PROXY_CIDRS, every client shares the proxy’s one allowance Set FRONTEND_TRUSTED_PROXY_CIDRS; point a fast monitor at the api directly rather than through the frontend
AI chat or log view hangs or stays empty The proxy buffers responses or times out after 60 s proxy_buffering off, read timeout of an hour or more (section 4)
Blank page, or 404 on /assets/... Served under a sub-path Not supported: use a hostname of its own
Backup restore or slot-image upload fails with 413 Proxy body-size limit Raise it (client_max_body_size 4g)

To see what the api actually receives, send a request through the proxy to /api/v1/auth/login with a wrong password and look at the source IP of the resulting audit row: it must be your client, not the proxy.