diff --git a/backend/deploy/Caddyfile b/backend/deploy/Caddyfile new file mode 100644 index 0000000..0b54d30 --- /dev/null +++ b/backend/deploy/Caddyfile @@ -0,0 +1,26 @@ +# Caddy reverse proxy configuration for Xtablo production. +# +# Caddy automatically: +# - Provisions a TLS certificate via Let's Encrypt (ACME) when a domain name is provided. +# - Redirects HTTP (port 80) to HTTPS (port 443). +# - Renews the certificate before it expires. +# +# Required environment variable: +# DOMAIN — set this to your production domain in .env.prod (e.g. app.yourdomain.com). +# Caddy reads {$DOMAIN} from the environment at startup. +# +# Let's Encrypt staging note (RESEARCH Pitfall 4): +# Let's Encrypt enforces rate limits (5 duplicate certificates per week per domain). +# For initial setup and testing, add a global block to use the staging endpoint: +# +# { +# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory +# } +# +# Remove the global block (or comment it out) before going live to switch to +# production certificates. The caddy_data volume must be cleared between staging +# and production to avoid certificate cache conflicts. + +{$DOMAIN} { + reverse_proxy web:8080 +} diff --git a/backend/docker-compose.prod.yaml b/backend/docker-compose.prod.yaml new file mode 100644 index 0000000..83f8301 --- /dev/null +++ b/backend/docker-compose.prod.yaml @@ -0,0 +1,91 @@ +# Production compose stack for Hetzner VM deployment. +# +# Decision references (from CONTEXT.md / RESEARCH.md): +# D-01: Hetzner VM as the production host — single VPS, plain Docker Compose. +# D-02: No orchestration layer — plain docker compose up on the VM. +# D-03: Postgres runs inside the compose stack (not an external managed DB). +# D-04: Caddy handles TLS via Let's Encrypt (persistent caddy_data volume required). +# D-08: Same image built once; web and worker are launched via different `command:` values. +# +# Usage: +# export IMAGE=ghcr.io/yourusername/xtablo TAG=v0.1.0 +# docker compose -f docker-compose.prod.yaml --env-file .env.prod up -d + +services: + + # ------------------------------------------------------------------------- + # Postgres (D-03) — internal only; no host port binding (RESEARCH Pitfall 5) + # ------------------------------------------------------------------------- + postgres: + image: postgres:16-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB:-xtablo} + POSTGRES_USER: ${POSTGRES_USER:-xtablo} + # No default — operator MUST set POSTGRES_PASSWORD in .env.prod + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres_data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xtablo}"] + interval: 10s + timeout: 5s + retries: 10 + # No `ports:` — Postgres is internal to the compose network only. + # Exposing port 5432 to the host would allow unauthenticated internet access + # on a VM with a public IP (RESEARCH Pitfall 5 / threat T-07-09). + + # ------------------------------------------------------------------------- + # Web server (D-08) — /app/web binary from the shared image + # ------------------------------------------------------------------------- + web: + image: ${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest} + command: /app/web + restart: unless-stopped + env_file: .env.prod + depends_on: + postgres: + condition: service_healthy + expose: + - "8080" + # No `ports:` — Caddy handles all external traffic and reverse-proxies to web:8080. + + # ------------------------------------------------------------------------- + # Background worker (D-08) — /app/worker binary from the same image + # ------------------------------------------------------------------------- + worker: + image: ${IMAGE:-ghcr.io/yourusername/xtablo}:${TAG:-latest} + command: /app/worker + restart: unless-stopped + env_file: .env.prod + depends_on: + postgres: + condition: service_healthy + + # ------------------------------------------------------------------------- + # Caddy reverse proxy (D-04) — TLS termination via Let's Encrypt + # ------------------------------------------------------------------------- + caddy: + image: caddy:2-alpine + restart: unless-stopped + ports: + - "80:80" + - "443:443" + - "443:443/udp" + volumes: + - ./deploy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy_data:/data + - caddy_config:/config + depends_on: + - web + +volumes: + # Postgres data — persist across container restarts and upgrades. + postgres_data: + + # Caddy TLS data — stores Let's Encrypt certificates. + # Losing this volume forces certificate re-issuance and may hit LE rate limits. + caddy_data: + + # Caddy runtime config — persists across Caddy restarts. + caddy_config: