Skip to main content

ADR-0014: Caddy origin TLS + GHCR image pipeline for the shared stack

Status: Accepted Date: 2026-07-30

Context

ADR-0013 made one shared multi-tenant stack the hosted product, but the deployment machinery still assumed the per-client model: a Traefik edge on :80 only, TLS terminated at Cloudflare in Flexible mode (the Cloudflare→origin hop crossed the internet as plain HTTP — ADR-0008 names this as its weakest link), images built on the VPS from a git checkout with no registry, and no CI deploy path, so "rollback" meant git archaeology plus a rebuild.

Two additional constraints shaped the redesign:

  • The backend derives the client IP from the last X-Forwarded-For entry (RequestUtils.resolveClientIp) and keys rate limiting, account lockout, and audit logging on it. That is sound with exactly one trusted proxy hop; every additional appending hop replaces the real client with a container IP and collapses all users into one rate-limit bucket.
  • Flyway has no down-migrations, so any rollback story must work with an older jar booting against a newer schema.

Decision

  • Caddy 2 terminates TLS on the VPS (deploy/Caddyfile, deploy/docker-compose.prod.yml) with automatic Let's Encrypt certificates; Cloudflare stays in front, upgraded to Full (strict). HSTS is emitted at Caddy and nowhere else.
  • Caddy proxies /api/* and /ws straight to the backend, bypassing the frontend nginx, and overwrites X-Forwarded-For with CF-Connecting-IP. The header is trustworthy because ports 80/443 are firewalled to Cloudflare's ranges in the DOCKER-USER chain — the firewall is part of the security model. The frontend nginx serves only the static SPA; its baked /api proxy block stays (dev and the legacy shape still use it) but receives no traffic here.
  • Images are built by GitHub Actions and pushed to GHCR (ghcr.io/jomariabejo/motorph-payroll-{backend,frontend}), tagged sha-<12-hex> per commit (deploy.yml). The VPS never builds; the prod compose file has no build:.
  • Deploys are SSH-driven and health-gated: deploy/deploy.sh pulls first, takes a pre-deploy pg_dump, swaps the tag, waits on the container healthcheck, and automatically redeploys the previous tag on failure — still exiting non-zero so the run goes red. A manual rollback.yml flips to any recorded tag.
  • Rollback tolerates the newer schema via a compose overlay (deploy/docker-compose.rollback.yml) setting SPRING_FLYWAY_IGNORE_MIGRATION_PATTERNS=*:missing. The schema itself is never rolled back; a destructive migration means restoring the pre-deploy dump.

Consequences

Positive

  • The Cloudflare→origin hop is finally encrypted and verified (Full strict); ADR-0008's accepted gap is closed for the hosted shape.
  • Rollback is a tag flip measured in seconds, with an automatic first responder built into every deploy.
  • Real client IPs reach the rate limiter and audit log; the shared-bucket failure mode is designed out and smoke-tested (vps-guide §8).
  • VPS CPU/RAM are no longer spent on Maven/npm builds.

Negative

  • Deploys have downtime (compose recreate + health gate) — accepted at this scale instead of blue/green.
  • The trust chain (CF-Connecting-IP → firewall) is load-bearing: dropping the DOCKER-USER allowlist silently degrades IP-keyed security to a spoofable header. The guide makes the firewall mandatory, not optional.
  • GitHub Actions and GHCR become production dependencies (a registry outage blocks deploys, not uptime).
  • Deploys are gated on the container healthcheck, not tests — the backend's known-failing billing tests block a test gate until fixed.
  • ADR-0008 remains the shape for legacy per-client on-prem installs; two edge models now coexist in the repo.

References