Skip to content

infra(compose): add Caddy edge reverse proxy with ACME (issue #155) - #160

Merged
tradem merged 1 commit into
mainfrom
feature/155-caddy-edge-reverse-proxy
Aug 1, 2026
Merged

infra(compose): add Caddy edge reverse proxy with ACME (issue #155)#160
tradem merged 1 commit into
mainfrom
feature/155-caddy-edge-reverse-proxy

Conversation

@tradem

@tradem tradem commented Aug 1, 2026

Copy link
Copy Markdown
Owner

Summary

Implements ADR-025 (issue #155): HTTPS edge transport via Caddy as the sole public entry point, with automatic ACME certificate issuance/rotation (HTTP-01 by default). The api container becomes Docker-internal only — its host port publication (:3000) is removed from the production compose.

Changes

backend/docker-compose.prod.yml

  • New caddy service, digest-pinned (caddy:2.9.1-alpine@sha256:b4e39523…a534f0):
    • Publishes only :80 (HTTP→HTTPS redirect + ACME HTTP-01 challenge) and :443 (TLS). Admin API (:2019) stays internal-only.
    • DOMAIN is now a required env var (${DOMAIN:?DOMAIN is required}); PHOTO_MAX_SIZE_MB is passed to Caddy so the edge ceiling stays aligned with the API.
    • Mounts the version-controlled ./Caddyfile read-only; persistent caddy_data (ACME account + certs) and caddy_config volumes — to be placed on the LUKS-encrypted volume (ADR-023).
  • api service: host ports: removed — reachable only on the Docker network from Caddy.

backend/Caddyfile (new, version-controlled)

  • Domain placeholder {$DOMAIN}; explicit modern-TLS floor (tls1.2 tls1.3).
  • HSTS (max-age=31536000; includeSubDomains), X-Content-Type-Options: nosniff, Server header removed.
  • Edge request-body ceiling request_body { max_size {$PHOTO_MAX_SIZE_MB:20}MB } aligned with the API.
  • Default-deny posture: unknown hosts on :80 → explicit 404 catch-all; unknown SNI on :443 → TLS-layer reject (no cert served); unknown paths → the API's own 404 fallback. No file_server, no catch-all that returns 200. (The :80 catch-all was verified not to interfere with automatic HTTPS / the HTTP-01 challenge; a :443 catch-all is deliberately avoided to prevent ACME rate-limit abuse.)

Dev overlay (optional scope item)

  • backend/Caddyfile.dev + backend/docker-compose.caddy.yml: local HTTPS testing with Caddy's internal CA (tls internal), network_mode: host → proxies to 127.0.0.1:3000 (host-run cargo run -p api). Plain tag allowed for dev, per the issue.

Docs

  • backend/docs/operations/runbooks.md: Caddy tier, boot prerequisites (DOMAIN), caddy_data backup (ACME state / rate-limit protection), digest-pinning entry, healthcheck note, new § HTTPS edge & certificates (ADR-025) incl. DNS-01 follow-up (vault-sourced per ADR-027).
  • backend/docs/issues/issue-155-caddy-edge-reverse-proxy.md: systematic implementation plan + verified results.

CI guardrail

  • .github/workflows/ci.yml — new compose-config job: docker compose config --quiet for all four compose combinations and caddy validate for both Caddyfiles (daemon-free; digest-pinned image), mechanically enforcing the “docker compose config validates” acceptance criterion.

Verification

  • docker compose config passes for prod, dev+caddy, dev+idp, dev+idp+caddy
  • caddy validate passes for Caddyfile and Caddyfile.dev (env placeholders resolved)
  • Live HTTPS smoke test (dev overlay + dummy backend):
    • HTTPS 200 with Strict-Transport-Security: max-age=31536000; includeSubDomains, nosniff, no Server header
    • HTTP → HTTPS 308 redirect
    • Unknown host on :80404; unknown SNI on :443 → TLS-level reject; unknown path → proxied app 404
    • 25 MB body → 413; 5 MB body → 200 (edge ceiling aligned with PHOTO_MAX_SIZE_MB)
  • gitleaks detect — no leaks

Out of scope (tracked elsewhere, per issue)

  • /swagger-ui production authz lockdown — deployment concern (ADR-025)
  • Host firewall rules for :80/:443 — ADR-026 / VPS runbook
  • step-ca / internal DB TLS — ADR-024 (separate issue)
  • DNS-01 challenge via a Caddy DNS-provider plugin — documented follow-up (credentials via vault, ADR-027)

Open questions (before first prod boot)

  • Final production DOMAIN value and DNS provider (HTTP-01 default).

Closes #155

Co-authored-by: deepseek-v4-flash (opencode-go)

HTTPS edge transport per ADR-025: Caddy terminates TLS with automatic
ACME issuance/rotation (HTTP-01) and is the sole public entry point.
The api container is now Docker-internal only (host port :3000 removed).

- docker-compose.prod.yml: caddy service (digest-pinned 2.9.1-alpine),
  :80/:443 only, caddy_data/caddy_config volumes (LUKS volume, ADR-023),
  DOMAIN required; api ports removed
- Caddyfile (prod): domain placeholder, TLS 1.2+ floor, HSTS + nosniff +
  -Server, request_body ceiling aligned with PHOTO_MAX_SIZE_MB,
  default-deny posture (unknown host :80 -> 404, unknown SNI -> TLS reject,
  unknown paths -> app 404), DNS-01 documented as follow-up (ADR-027)
- docker-compose.caddy.yml + Caddyfile.dev: local HTTPS dev overlay
  (tls internal, host networking)
- runbooks.md: Caddy tier, boot prerequisites, ACME-state backup,
  digest-pinning, HTTPS edge section
- ci.yml: compose-config job validating all compose combinations + caddy
  validate for both Caddyfiles
- docs/issues/issue-155: plan + verified results

Verified: docker compose config (4 combos), caddy validate, local HTTPS
smoke test (HSTS, 308 redirect, 404 default-deny, 413 body limit), gitleaks clean.
@tradem
tradem merged commit cf5427b into main Aug 1, 2026
8 checks passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

infra(compose): add Caddy edge reverse proxy with ACME (ADR-025)

1 participant