From 872d986f644510e9612d664ccaf1a17caf3bf13e Mon Sep 17 00:00:00 2001 From: Richard Hope Date: Tue, 21 Jul 2026 22:04:35 +1000 Subject: [PATCH] Compose: make Caddy the default TLS front end (TTX/EBS parity) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The Caddy reverse proxy moves from the opt-in `tls` profile to the default `docker compose up` path, matching the sibling IcebergTTX/EBS stacks: Caddy publishes the only public ports (:80/:443, +443/udp for HTTP/3) with automatic HTTPS (local CA for `localhost`, Let's Encrypt for a real ICEBERG_DOMAIN), while the app's plain-HTTP :8000 publish stays loopback-only as a debug side door. Hardening mirrors TTX: a one-shot caddy-init chowns the cert/config volumes so caddy runs non-root (uid 1000) with cap_drop ALL + NET_BIND_SERVICE on a read-only rootfs; the image pin moves to caddy:2-alpine. The Caddyfile gains upstream dial/response-header timeouts so a stalled worker returns 502 instead of holding client connections. Also adds the `docker-compose` Dependabot ecosystem (EBS parity) so the postgres/redis/caddy compose pins get update PRs — the Dockerfile `docker` ecosystem only reads FROM lines. The compose-render test drops the profile flag and now asserts the caddy service's port/user/capability/dependency invariants. Co-Authored-By: Claude Fable 5 --- .github/dependabot.yml | 13 +++++++ README.md | 64 ++++++++++++++---------------- deploy/Caddyfile | 24 ++++++++---- docker-compose.yml | 75 ++++++++++++++++++++++++++++-------- tests/test_prod_hardening.py | 20 +++++++++- 5 files changed, 137 insertions(+), 59 deletions(-) diff --git a/.github/dependabot.yml b/.github/dependabot.yml index 574d753..c9cd2e4 100644 --- a/.github/dependabot.yml +++ b/.github/dependabot.yml @@ -41,3 +41,16 @@ updates: schedule: interval: weekly open-pull-requests-limit: 5 + + # The postgres/redis/caddy image pins in docker-compose.yml, matching the + # sibling repos' compose tracking (the Dockerfile ecosystem above only reads + # FROM lines, not compose `image:` keys). + - package-ecosystem: docker-compose + directory: "/" + schedule: + interval: weekly + groups: + compose-images: + patterns: + - "*" + open-pull-requests-limit: 5 diff --git a/README.md b/README.md index 05ce7ca..962458e 100644 --- a/README.md +++ b/README.md @@ -486,13 +486,19 @@ needs shared storage (RWX volume or object store) — a follow-on to the datasto [`deploy/k8s/`](deploy/k8s/) and [`docker-compose.yml`](docker-compose.yml). Compose runs a **single** app service always paired with its `postgres` database container. The default Compose path also includes Redis for rate-limit buckets shared across uvicorn workers -(`ICEBERG_RATE_LIMIT_REDIS_URL=redis://redis:6379/0`) and exposes the app on loopback only: +(`ICEBERG_RATE_LIMIT_REDIS_URL=redis://redis:6379/0`) and a **Caddy TLS front end** on +`:80`/`:443` (same shape as the sibling IcebergTTX/EBS stacks); the app's plain-HTTP `:8000` +stays published on loopback only, as a local/debug side door: ```bash -docker compose up # app on http://localhost:8000 + PostgreSQL (no .env needed) +docker compose up # https://localhost via Caddy + PostgreSQL (no .env needed) cp .env.example .env # optional: customise settings, then re-run ``` +For the default `localhost` Caddy issues a certificate from its own local CA, so the browser +shows a one-time trust warning (or import Caddy's root CA); plain +also still works locally. + A fresh clone needs no pre-step — `.env` is optional (`env_file` is `required: false`) and is merged in automatically when present. For production-style Compose, set `ICEBERG_ENVIRONMENT=prod`, a real `ICEBERG_SECRET_KEY`, `ICEBERG_AUTO_MIGRATE=false`, and @@ -500,15 +506,17 @@ non-default `POSTGRES_*` credentials. ### TLS / running behind a proxy Iceberg always runs behind a **TLS-terminating reverse proxy** — a Kubernetes ingress, a cloud -load balancer, or (for a single-host Docker deployment) the opt-in **Caddy** profile: +load balancer, or (for a single-host Docker deployment) the bundled **Caddy** front end, which +Compose starts by default: ```bash -ICEBERG_DOMAIN=intel.example.com docker compose --profile tls up # auto Let's Encrypt TLS +ICEBERG_DOMAIN=intel.example.com docker compose up # auto Let's Encrypt TLS (needs public :80/:443 + DNS) ``` Caddy ([`deploy/Caddyfile`](deploy/Caddyfile)) terminates TLS and proxies to the app; pair it with -`ICEBERG_ENVIRONMENT=prod` for `Secure` cookies + HSTS. In this profile Caddy publishes -`:80`/`:443`, while the app's plain-HTTP `:8000` publish remains loopback-only. The container starts uvicorn with +`ICEBERG_ENVIRONMENT=prod` for `Secure` cookies + HSTS. Caddy publishes +`:80`/`:443` (the only public ports; it runs non-root with all capabilities dropped except +`NET_BIND_SERVICE`, on a read-only rootfs), while the app's plain-HTTP `:8000` publish remains loopback-only. The container starts uvicorn with `--proxy-headers` and trusts `X-Forwarded-*` only from `FORWARDED_ALLOW_IPS` (Compose scopes this to its dedicated proxy network; Kubernetes requires the ingress pod CIDR) so the request scheme is correct and the **audit log records the real client IP** rather than the proxy's. On **Kubernetes**, terminate TLS at an Ingress @@ -663,7 +671,7 @@ flowchart TB ``` ### Deployment topologies -Four supported shapes, from a zero-dependency local run to Kubernetes. Cylinders are persistent +Three supported shapes, from a zero-dependency local run to Kubernetes. Cylinders are persistent storage; dashed links are config/credential injection. **1. Local development** — uvicorn with the SQLite default and on-disk working dirs. No external @@ -676,46 +684,32 @@ flowchart LR App --> FS["Local dirs
./attachments · ./figures · ./rendered"] ``` -**2. Docker Compose (default)** — one app service paired with its own PostgreSQL container on the -compose network; only the app publishes a host port, bound to loopback (`localhost:8000`). Each -service has its own named volume. +**2. Docker Compose (default)** — a Caddy reverse proxy terminates TLS (Let's Encrypt for a real +`ICEBERG_DOMAIN`, a local-CA cert for the default `localhost`) and forwards `X-Forwarded-*`; the +app trusts those headers (`--proxy-headers`) so the scheme and client IP are correct. Caddy +publishes `:80`/`:443` — the only public ports; the app service pairs with its own PostgreSQL +container on the compose network and keeps a loopback-only plain-HTTP publish +(`localhost:8000`) as a local/debug side door. Each service has its own named volume. ```mermaid flowchart TB - Client["Browser / API client"] -->|"localhost:8000"| App + Client["Browser / API client"] -->|"https :443 · http→https :80"| Caddy + Client -.->|"localhost:8000 (loopback debug)"| App - subgraph net["docker compose — default bridge network"] - App["iceberg service
FastAPI + uvicorn --proxy-headers
container :8000"] + subgraph net["docker compose — iceberg-internal network"] + Caddy["caddy service
TLS termination (Let's Encrypt / local CA)
:80 · :443 · non-root, read-only"] + App["iceberg service
FastAPI + uvicorn --proxy-headers · :8000
prod → Secure cookies + HSTS"] PG["postgres service
postgres:17 · :5432 (network-internal)"] + Caddy -->|"reverse proxy + X-Forwarded-*"| App App -->|"postgresql+psycopg://iceberg@postgres:5432"| PG end + Caddy --- CV[("caddy-data · caddy-config
certificates")] App --- V1[("iceberg-data volume
/data: attachments · figures · rendered")] PG --- V2[("iceberg-pg-data volume
/var/lib/postgresql/data")] ``` -**3. Docker Compose + TLS (`--profile tls`)** — adds a Caddy reverse proxy that terminates TLS and -forwards `X-Forwarded-*`; the app trusts those headers (`--proxy-headers`) so the scheme and client -IP are correct. Caddy publishes `:80`/`:443`; the app's plain-HTTP publish remains loopback-only. - -```mermaid -flowchart TB - Client["Browser"] -->|"https :443 · http→https :80"| Caddy - - subgraph net["docker compose --profile tls"] - Caddy["caddy service
TLS termination (Let's Encrypt / local CA)
:80 · :443"] - App["iceberg service
uvicorn --proxy-headers · :8000
prod → Secure cookies + HSTS"] - PG["postgres service · :5432"] - Caddy -->|"reverse proxy + X-Forwarded-*"| App - App -->|"psycopg"| PG - end - - Caddy --- CV[("caddy-data · caddy-config
certificates")] - App --- V1[("iceberg-data
/data")] - PG --- V2[("iceberg-pg-data")] -``` - -**4. Kubernetes** — an Ingress terminates TLS to a ClusterIP Service and the single-replica +**3. Kubernetes** — an Ingress terminates TLS to a ClusterIP Service and the single-replica Deployment (`Recreate`, non-root, read-only rootfs). A migrate Job runs `alembic upgrade head` out of band; config comes from a ConfigMap (non-secret) and the `ICEBERG_DATABASE_URL`/secrets from a Secret. PostgreSQL is a managed instance or the optional StatefulSet; uploads/renders live on a diff --git a/deploy/Caddyfile b/deploy/Caddyfile index a31cebb..8d83d2e 100644 --- a/deploy/Caddyfile +++ b/deploy/Caddyfile @@ -1,9 +1,10 @@ -# Caddy reverse proxy for a single-host Docker deployment (opt-in `tls` profile; -# see docker-compose.yml). Set ICEBERG_DOMAIN to your public hostname for -# automatic Let's Encrypt TLS; it defaults to `localhost`, for which Caddy issues -# a local-CA certificate (handy for a demo). Caddy forwards X-Forwarded-For/-Proto -# by default, which the app honours via uvicorn's --proxy-headers (so the real -# client IP reaches the audit log and the scheme is correct for Secure cookies). +# Caddy reverse proxy — the default front end for a single-host Docker +# deployment (see docker-compose.yml). Set ICEBERG_DOMAIN to your public +# hostname for automatic Let's Encrypt TLS; it defaults to `localhost`, for +# which Caddy issues a local-CA certificate (handy for a demo or local run). +# Caddy forwards X-Forwarded-For/-Proto by default, which the app honours via +# uvicorn's --proxy-headers (so the real client IP reaches the audit log and +# the scheme is correct for Secure cookies). {$ICEBERG_DOMAIN:localhost} { # Proxy-level request-body cap. Keep this at/above ICEBERG_MAX_BODY_MB (default # 30 MB, itself just above ICEBERG_ATTACHMENT_MAX_MB=25 MB) so real uploads @@ -12,5 +13,14 @@ request_body { max_size 30MB } - reverse_proxy iceberg:8000 + # Security headers (CSP, HSTS, X-Frame-Options, …) are emitted by the app's + # SecurityHeaders middleware — the single source of truth — so Caddy adds + # nothing here. The transport timeouts bound a hung upstream so a stalled app + # worker returns 502 instead of holding client connections open indefinitely. + reverse_proxy iceberg:8000 { + transport http { + dial_timeout 10s + response_header_timeout 60s + } + } } diff --git a/docker-compose.yml b/docker-compose.yml index efa3d65..ba93185 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -2,12 +2,14 @@ # zero-dependency local dev/test default only; it is NOT a container deployment # option — the app refuses to boot on SQLite when ICEBERG_ENVIRONMENT=prod.) # -# docker compose up # app on localhost:8000 + PostgreSQL +# docker compose up # https://localhost via Caddy (local-CA cert) + PostgreSQL # ICEBERG_DOMAIN=intel.example.com \ -# docker compose --profile tls up # add a Caddy TLS reverse proxy on :80/:443 +# docker compose up # automatic Let's Encrypt TLS on the real domain # -# A single app service always pairs with the `postgres` service, so no two -# services ever bind the same host port. +# Caddy is the default public entry point (:80/:443), matching the sibling +# IcebergTTX/EBS stacks; the app's plain-HTTP :8000 stays published on loopback +# only as a local/debug side door. A single app service always pairs with the +# `postgres` service, so no two services ever bind the same host port. services: iceberg: build: . @@ -85,31 +87,74 @@ services: retries: 10 networks: [iceberg-internal] - # Opt-in TLS reverse proxy for a single-host Docker deployment: - # ICEBERG_DOMAIN=intel.example.com docker compose --profile tls up - # Caddy terminates TLS (automatic Let's Encrypt for a real domain; a local-CA - # cert for the default `localhost`) and proxies to the app over the compose - # network. For real production also set ICEBERG_ENVIRONMENT=prod in .env - # (Secure cookies + HSTS), ICEBERG_AUTO_MIGRATE=false, a real - # ICEBERG_SECRET_KEY and non-default Postgres credentials. The app's plain HTTP - # port is bound to loopback, so Caddy is the public entry point. + # TLS reverse proxy — the default front end for a single-host Docker + # deployment (same shape as the sibling IcebergTTX/EBS stacks). Caddy + # terminates TLS (automatic Let's Encrypt for a real ICEBERG_DOMAIN; a + # local-CA cert for the default `localhost`) and proxies to the app over the + # compose network. For real production also set ICEBERG_ENVIRONMENT=prod in + # .env (Secure cookies + HSTS), ICEBERG_AUTO_MIGRATE=false, a real + # ICEBERG_SECRET_KEY and non-default Postgres credentials. The app's plain + # HTTP port is bound to loopback, so Caddy is the public entry point. + caddy-init: + # One-shot: the named cert/config volumes initialise root-owned from the + # image, so chown them for the non-root caddy user below (k8s does the same + # job with fsGroup). Runs and exits before caddy starts. + image: caddy:2-alpine + user: "0:0" + entrypoint: ["/bin/sh", "-c", "chown -R 1000:1000 /data /config"] + volumes: + - caddy-data:/data + - caddy-config:/config + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - CHOWN + # Caddy creates data/caddy/{certificates,locks,pki} mode 0700 owned by uid + # 1000. Root cannot descend into them without its DAC bypass, which + # cap_drop ALL removed — so on every run after the first, chown -R fails + # EPERM without DAC_READ_SEARCH. + - DAC_READ_SEARCH + restart: "no" + networks: [iceberg-internal] + caddy: - profiles: ["tls"] - image: caddy:2 + image: caddy:2-alpine restart: unless-stopped + # Non-root (the official image defaults to root): run as uid 1000. The caddy + # binary ships the cap_net_bind_service file capability, so NET_BIND_SERVICE + # must stay in the bounding set (cap_add below) — both so exec of the setcap + # binary succeeds under cap_drop ALL and so the non-root process can bind + # :80/:443. + user: "1000:1000" depends_on: iceberg: condition: service_healthy + caddy-init: + condition: service_completed_successfully environment: + # localhost (local-CA self-signed) | your.domain.com (Let's Encrypt) ICEBERG_DOMAIN: ${ICEBERG_DOMAIN:-localhost} ports: - "80:80" - "443:443" + - "443:443/udp" # HTTP/3 volumes: - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro - - caddy-data:/data + - caddy-data:/data # issued certs — persist across restarts (required) - caddy-config:/config networks: [iceberg-internal] + # Read-only rootfs with the cert/config stores on volumes and a writable /tmp. + security_opt: + - no-new-privileges:true + cap_drop: + - ALL + cap_add: + - NET_BIND_SERVICE + read_only: true + tmpfs: + - /tmp networks: iceberg-internal: diff --git a/tests/test_prod_hardening.py b/tests/test_prod_hardening.py index 09a1eb7..83f2d82 100644 --- a/tests/test_prod_hardening.py +++ b/tests/test_prod_hardening.py @@ -100,8 +100,6 @@ def test_compose_prod_overrides_and_loopback_port(tmp_path): str(env_file), "-f", str(_REPO_ROOT / "docker-compose.yml"), - "--profile", - "tls", "config", "--format", "json", @@ -139,3 +137,21 @@ def test_compose_prod_overrides_and_loopback_port(tmp_path): "protocol": "tcp", } ] + + # Caddy is the default front end (no profile flag needed): sole public + # ports, non-root, minimal capabilities, gated on a healthy app. + caddy = config["services"]["caddy"] + assert {(p["target"], p["protocol"]) for p in caddy["ports"]} == { + (80, "tcp"), + (443, "tcp"), + (443, "udp"), + } + assert all("host_ip" not in p for p in caddy["ports"]) # public, not loopback + assert caddy["user"] == "1000:1000" + assert caddy["cap_drop"] == ["ALL"] + assert caddy["cap_add"] == ["NET_BIND_SERVICE"] + assert caddy["read_only"] is True + assert caddy["depends_on"]["iceberg"]["condition"] == "service_healthy" + assert ( + caddy["depends_on"]["caddy-init"]["condition"] == "service_completed_successfully" + )