ADR 0005: The Caddy proxy and the LiteLLM gateway components¶
Context¶
This decision adds two optional components to the software template:
proxy-caddy, the one public entry point, which is on by default;gateway-litellm, a model gateway, which is off by default.
Both need backend-django, and the pre-generation hook refuses a project
that asks for either without it.
The proxy must be an unprivileged, digest-pinned public entry point with a host allowlist, trusted forwarding headers, modern Transport Layer Security (TLS), security headers, request limits, structured logs, streaming and container hardening. Caddy was chosen instead of nginx, so the decision below states how Caddy meets each requirement.
The optional model gateway must be isolated from the database, refuse weak master-key configuration, avoid metadata and telemetry calls, expose a health check, hide its documentation endpoints, constrain credentials and reject models outside its visibility policy. The gateway is not public; only the backend can reach its network.
The facts about Caddy 2.11.4 and LiteLLM 1.103.2 behind each choice were tested against the pinned images on 2026-10-02, unless a line cites the documentation instead.
Decision¶
The proxy¶
| Requirement | How the template meets it |
|---|---|
| Unprivileged image, pinned by digest | caddy:2.11.4-alpine@sha256:…, run as uid 65532 |
| Configuration from the environment, filtered | Caddy reads {$SITE_ADDRESS:localhost} itself; the backend's .env.example test scans the Caddyfile |
| Host allowlist | one site address; http://, https:// { abort } closes any other host's connection unanswered |
Client X-Forwarded-* and Forwarded replaced |
Caddy replaces X-Forwarded-For, -Proto and -Host, but passes Forwarded through, so the Caddyfile removes it |
X-Forwarded-Proto from the real scheme |
the same replacement; make smoke-proxy sends a spoofed http and still gets 200 |
server_tokens off |
-Server and -Via on every response, Caddy's own error pages included |
| Loopback only for development | 127.0.0.1:${PROXY_PORT:-8443}; compose.deploy.yaml publishes 80 and 443 on a server |
| TLS 1.2 and 1.3, Mozilla intermediate | Caddy's default cipher suites, with protocols tls1.2 tls1.3 written out |
| HSTS | set by the proxy; Caddy does not add it on its own |
| Explicit body limit | request_body max_size 10MB (see "Left out or changed", item 2) |
| JSON access logs | log { output stdout format json } |
| Unbuffered streaming, App Router pages included | flush_interval -1 on every upstream |
| Permissions-Policy, COOP and CORP in one place | the proxy sets them and replaces an upstream's copy; the smoke check counts exactly one of each on a backend path and a frontend page |
read_only, cap_drop, no-new-privileges, limits |
all four, plus cap_add: NET_BIND_SERVICE: the image grants the binary that file capability, and exec fails without it |
| Tests of each | make proxy-check runs caddy fmt and caddy validate; make smoke-proxy checks TLS, headers, forwarding, the redirect and unknown hosts in the running stack |
The proxy also strips x-middleware-subrequest, the header
behind CVE-2025-29927. The Caddyfile removes it before any upstream sees it.
Additional settings come from the tests:
admin off;skip_install_trust, since the root filesystem is read-only;protocols h1 h2, since HTTP/3'sAlt-Svcheader would advertise the container's port 8443 rather than the published 443;- a header-read timeout and a maximum header size.
The container's health check and metrics are on a loopback-only site, port 2020.
The gateway¶
| Requirement | How the template meets it |
|---|---|
| ≥ 1.102.2 or 1.103.1, by digest (GHSA-7hp6-4w63-5g45) | litellm-non_root:v1.103.2@sha256:…, outside every affected range |
Refuse to start without a master key, checked for sk- |
the entrypoint refuses any key not matching sk-?* and exits 64; LiteLLM itself started with a key lacking the prefix |
LITELLM_LOCAL_*, no outbound metadata calls |
all five flags the image reads |
| Its own network, away from the database | gateway-edge, shared with the backend only |
/health/liveliness health check |
yes, through the image's python3, since it has no curl or wget |
drop_params: false |
yes |
LITELLM_MODE=PRODUCTION, json_logs, LITELLM_LOG=ERROR |
yes |
NO_DOCS, NO_REDOC, NO_OPENAPI |
yes; the smoke check gets 404 from /docs and /openapi.json |
request_timeout, workers, restart after N requests |
600 s, one worker, 10 000 requests |
trusted_proxy_ranges: [], no env or client credentials |
yes |
Drop --telemetry False |
not passed |
| Empty tier table; unknown visibility goes to local only | model_list: []; gateway/policy.py refuses with 403 any request for a non-local/ model whose visibility is not public |
The gateway runs under its own compose profile, so --profile app never
starts it. make smoke-gateway first checks the refusal of a bad key. It
then starts the gateway with gateway/tests/smoke.yaml, which adds two
mock models that answer without a network, and checks:
- the key;
- the health endpoint;
- docs off;
- each case of the visibility policy.
Left out or changed¶
- No rate limiting. Caddy has no
built-in rate limiter. The module that adds one,
mholt/caddy-ratelimit, is third-party, at v0.1.0, and needs a custom build withxcaddy. That would replace a pinned upstream image with one the project builds and patches itself. A project that needs rate limiting adds it at the host's edge or builds that image deliberately. - The body limit is enforced on read, and not tested. Caddy applies
request_body max_sizeas the upstream reads the body: a request over the limit gets 413 only once an upstream reads that far. A test that sent 11 MB had its connection reset mid-send instead of getting a status code, so the smoke check does not include it. Django refuses form bodies over 2.5 MB on its own. - No virtual keys.
LITELLM_SALT_KEYand a database are needed only when virtual keys are wanted. The template offers no virtual keys, so it sets neither. LiteLLM uses the master key as the salt when none is set. A project that adds virtual keys sets the salt key once, before the first key is issued, and never changes it. - The visibility comes from the request.
policy.pyreadsmetadata.visibilityfrom the caller, so it guards against a mistake by a trusted backend, not against a hostile client. That is why the gateway has no published port: only the backend, ongateway-edge, can reach it. - Caddy 2.11.4, not 2.11.6. On 2026-10-02, 2.11.6 was released on
GitHub but not on Docker Hub. 2.11.4 carries GHSA-6365-7ppr-5r92
(medium), which needs
forward_authandreverse_proxyon the same route. The template does not combine them. Raise the pin when 2.11.6 reaches Docker Hub.
Consequences¶
- The proxy is the default, so a new software project gets TLS, HSTS and
forwarded-header handling unless the creator turns it off. Django's
DJANGO_TRUST_FORWARDED_PROTOandDJANGO_NUM_PROXIESare set only when the proxy is chosen. - The images in
compose.yamlsit in a Jinja file, so Dependabot does not raise them (ADR 0004, decision 4). foundry's test checks that each image is pinned by digest, and raising the pin is manual. make ciin a project with either component now starts that component's container, so CI needs Docker, as it already did for the backend.