- ADR-020: Document defense-in-depth rationale for running in a minimal Docker container (memory-safe language + container isolation), flexible upstream addressing (Docker DNS, loopback, LAN, tunnel endpoints), file-primary logging for fail2ban, and volume mount strategy - ADR-016: Add allow_wildcard_bind override for container deployments where 0.0.0.0 is correct inside the container network namespace - operations.md: Add container deployment section with Docker Compose example, networking table, volume mounts, and health check integration; flip logging to file-primary for fail2ban reliability; note systemd as alternative to container deployment - config.md: Restructure logging fields into nested LoggingConfig (matching TOML [logging] section), add allow_wildcard_bind, shutdown_timeout_secs, and log_file_path fields; clarify upstream addressing supports Docker DNS and tunnel endpoints; update validation rule for 0.0.0.0 override - overview.md: Update architecture diagram for container model with Docker networking and volume mounts; add ADR-020 reference - proxy.md: Clarify X-Forwarded-Proto is determined by listener port, not hardcoded 80/443 - ADR-013: Fix health_check_port default contradiction (default is 9900, not 0/disabled as previously stated)
4.5 KiB
ADR-020: Container Deployment Model
Status
Accepted
Context
The reverse proxy replaces an nginx instance running directly on the host, which is vulnerable to multiple actively-exploited CVEs (including CVE-2026-42945, unauthenticated RCE). Rust's memory safety eliminates the bug class responsible for most nginx CVEs, but containerization adds a second independent barrier.
Running the proxy in a minimal Docker container means that even if an attacker finds a logic-level vulnerability in the proxy, they must also escape the container boundary. The probability of chaining a Rust proxy exploit with a container escape is materially lower than exploiting any single layer alone.
Additionally, the proxy must support flexible upstream addressing. While the initial deployment runs all services on the same host (Gitea, Postgres, the proxy itself in containers sharing a Docker network), upstreams may also be on different machines — reachable via LAN IP, VPN, or SSH-based tunnel (alknet). The configuration must not assume same-host deployment.
Finally, fail2ban integration requires reliable log access. Docker log drivers (journald, json-file) introduce complexity and fragility for log parsing. A log file on a volume mount is simpler, more reliable, and easier for fail2ban to consume directly from the host filesystem.
Decision
-
The proxy runs in a minimal Docker container (multi-stage build: compile in
rust:alpine, run inalpineorscratch). The container image contains only the static binary and necessary runtime files. -
Bind address
0.0.0.0is allowed inside containers via an explicit opt-in flag (allow_wildcard_bind = truein config, or--allow-wildcard-bindCLI flag). The default remains: reject0.0.0.0as a bind address. In a container, binding0.0.0.0is correct and safe because the container's network namespace is isolated — it only exposes interfaces Docker publishes via-pflags. See ADR-016 for the original rationale and this override. -
Upstream addressing is unopinionated. The
upstreamfield inSiteConfigaccepts anyhost:portcombination:- Docker DNS names (
gitea:3000) for containers on the same Docker network - Loopback addresses (
127.0.0.1:3000) for host-network or sidecar deployments - LAN IPs (
10.0.0.5:3000) for same-network services - VPN or tunnel endpoints as needed The proxy makes no assumptions about upstream locality.
- Docker DNS names (
-
Logging is file-primary for fail2ban integration. The proxy writes structured logs to a file (default:
/var/log/reverse-proxy/access.log) on a volume mount. stdout/stderr logging is always-on (fordocker logsandjournalctl). File logging is the authoritative source for fail2ban because it avoids the fragility of Docker log driver parsing. -
ACME state and admin socket are volume-mounted. The ACME cache directory (
/var/lib/reverse-proxy/acme-cache/) and admin socket (/run/reverse-proxy/admin.sock) are mounted as volumes so state persists across container restarts and the host can send reload commands. -
Health checks use Docker's native mechanism. The health check endpoint on port 9900 (localhost only) is used directly by Docker's
HEALTHCHECKdirective. No port publishing needed. -
SSH passthrough for Gitea remains on the host. The proxy does not handle SSH traffic. Port 22 traffic is routed directly to the Gitea container by Docker, not through the reverse proxy. This matches the current nginx deployment where SSH is independent.
Consequences
Positive:
- Defense-in-depth: memory-safe language + container isolation + minimal image
- Flexible upstream addressing supports same-host, LAN, VPN, and tunnel deployments without config changes
- File-based logging is simple and reliable for fail2ban — no Docker log driver parsing required
- ACME state persists across container restarts via volume mounts
- Health check integrates natively with Docker's orchestration
Negative:
- Slightly more complex deployment (Docker Compose, volume mounts, network configuration) compared to a bare binary
0.0.0.0override must be documented clearly to prevent misuse in non-container deployments- File logging requires a volume mount, adding a deployment step
- Container rebuilds are needed for binary updates (mitigated by CI/CD)