Create docker-compose.prod.yml with Caddy reverse proxy and TLS #136

Open
opened 2026-03-28 08:52:14 +01:00 by marcel · 0 comments
Owner

Why

The current docker-compose.yml is designed for local development:

  • Both services expose raw ports directly (backend on 8080, frontend on 5173).
  • There is no TLS — everything runs over plain HTTP.
  • Dev-only services (Mailpit, MinIO, create-buckets) run alongside the app.
  • Bind mounts and exposed DB ports are wrong for production (tracked in #131, #132).
  • No rate limiting, no gzip compression, no HTTP security headers.

A production VPS needs a reverse proxy (Caddy) sitting in front of both services, handling TLS termination, routing, and security headers. Caddy is the right choice here: automatic Let's Encrypt TLS with zero configuration, simple Caddyfile syntax, and it runs as a Docker service alongside the app.

What to do

1. Create Caddyfile

familienarchiv.example.com {
    # Security headers
    header {
        Strict-Transport-Security "max-age=31536000; includeSubDomains"
        X-Content-Type-Options "nosniff"
        X-Frame-Options "DENY"
        Referrer-Policy "strict-origin-when-cross-origin"
        -Server
    }

    # Route API and actuator health to backend
    handle /api/* {
        reverse_proxy backend:8080
    }

    # All other traffic goes to the SvelteKit frontend
    handle {
        reverse_proxy frontend:3000
    }

    # Compression
    encode gzip
}

Replace familienarchiv.example.com with the actual domain. Caddy will automatically obtain and renew a Let's Encrypt certificate for it.

2. Create docker-compose.prod.yml

This file is applied on top of docker-compose.yml using Docker Compose's overlay pattern (-f docker-compose.yml -f docker-compose.prod.yml). It only overrides what needs to change for production.

services:
  db:
    volumes:
      - postgres_data:/var/lib/postgresql/data  # named volume, not bind mount
    ports: []            # remove host port exposure — internal only
    expose:
      - "5432"

  minio:
    profiles: ["dev"]    # dev-only — production uses Hetzner Object Storage

  create-buckets:
    profiles: ["dev"]

  mailpit:
    profiles: ["dev"]

  backend:
    image: gitea.example.com/marcel/familienarchiv/backend:${IMAGE_TAG:-latest}
    build: null          # use pre-built image, don't build on the VPS
    volumes: []          # no source mount — image is self-contained
    environment:
      SPRING_PROFILES_ACTIVE: prod

  frontend:
    image: gitea.example.com/marcel/familienarchiv/frontend:${IMAGE_TAG:-latest}
    build: null
    volumes: []
    environment:
      ORIGIN: https://familienarchiv.example.com

  caddy:
    image: caddy:2-alpine
    restart: unless-stopped
    ports:
      - "80:80"
      - "443:443"
      - "443:443/udp"    # HTTP/3
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy_data:/data
      - caddy_config:/config
    networks:
      - archive-net
    depends_on:
      - frontend
      - backend

volumes:
  postgres_data:
  caddy_data:
  caddy_config:

3. Deployment command

docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d

4. DNS prerequisite

The domain must have an A record pointing to the VPS IP before starting Caddy, otherwise the Let's Encrypt HTTP-01 challenge will fail and Caddy will not start with TLS.

What this gives us

  • Automatic HTTPS with Let's Encrypt — zero certificate management
  • HTTP → HTTPS redirect (Caddy does this automatically)
  • HTTP/3 support via the UDP port
  • Security headers on all responses
  • gzip compression
  • Clean separation: backend and frontend are not reachable directly, only through Caddy
  • Dev services (MinIO, Mailpit) are excluded from the production process group

Acceptance criteria

  • docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d starts only db, backend, frontend, caddy.
  • https://familienarchiv.example.com serves the SvelteKit app with a valid TLS certificate.
  • https://familienarchiv.example.com/api/actuator/health returns {"status":"UP"}.
  • http://familienarchiv.example.com redirects to https://.
  • Port 8080 and 3000 are not directly accessible from outside the Docker network.
## Why The current `docker-compose.yml` is designed for local development: - Both services expose raw ports directly (backend on `8080`, frontend on `5173`). - There is no TLS — everything runs over plain HTTP. - Dev-only services (Mailpit, MinIO, create-buckets) run alongside the app. - Bind mounts and exposed DB ports are wrong for production (tracked in #131, #132). - No rate limiting, no gzip compression, no HTTP security headers. A production VPS needs a **reverse proxy** (Caddy) sitting in front of both services, handling TLS termination, routing, and security headers. Caddy is the right choice here: automatic Let's Encrypt TLS with zero configuration, simple `Caddyfile` syntax, and it runs as a Docker service alongside the app. ## What to do ### 1. Create `Caddyfile` ```caddyfile familienarchiv.example.com { # Security headers header { Strict-Transport-Security "max-age=31536000; includeSubDomains" X-Content-Type-Options "nosniff" X-Frame-Options "DENY" Referrer-Policy "strict-origin-when-cross-origin" -Server } # Route API and actuator health to backend handle /api/* { reverse_proxy backend:8080 } # All other traffic goes to the SvelteKit frontend handle { reverse_proxy frontend:3000 } # Compression encode gzip } ``` Replace `familienarchiv.example.com` with the actual domain. Caddy will automatically obtain and renew a Let's Encrypt certificate for it. ### 2. Create `docker-compose.prod.yml` This file is applied **on top of** `docker-compose.yml` using Docker Compose's overlay pattern (`-f docker-compose.yml -f docker-compose.prod.yml`). It only overrides what needs to change for production. ```yaml services: db: volumes: - postgres_data:/var/lib/postgresql/data # named volume, not bind mount ports: [] # remove host port exposure — internal only expose: - "5432" minio: profiles: ["dev"] # dev-only — production uses Hetzner Object Storage create-buckets: profiles: ["dev"] mailpit: profiles: ["dev"] backend: image: gitea.example.com/marcel/familienarchiv/backend:${IMAGE_TAG:-latest} build: null # use pre-built image, don't build on the VPS volumes: [] # no source mount — image is self-contained environment: SPRING_PROFILES_ACTIVE: prod frontend: image: gitea.example.com/marcel/familienarchiv/frontend:${IMAGE_TAG:-latest} build: null volumes: [] environment: ORIGIN: https://familienarchiv.example.com caddy: image: caddy:2-alpine restart: unless-stopped ports: - "80:80" - "443:443" - "443:443/udp" # HTTP/3 volumes: - ./Caddyfile:/etc/caddy/Caddyfile:ro - caddy_data:/data - caddy_config:/config networks: - archive-net depends_on: - frontend - backend volumes: postgres_data: caddy_data: caddy_config: ``` ### 3. Deployment command ```bash docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d ``` ### 4. DNS prerequisite The domain must have an A record pointing to the VPS IP **before** starting Caddy, otherwise the Let's Encrypt HTTP-01 challenge will fail and Caddy will not start with TLS. ## What this gives us - Automatic HTTPS with Let's Encrypt — zero certificate management - HTTP → HTTPS redirect (Caddy does this automatically) - HTTP/3 support via the UDP port - Security headers on all responses - gzip compression - Clean separation: backend and frontend are not reachable directly, only through Caddy - Dev services (MinIO, Mailpit) are excluded from the production process group ## Acceptance criteria - `docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d` starts only `db`, `backend`, `frontend`, `caddy`. - `https://familienarchiv.example.com` serves the SvelteKit app with a valid TLS certificate. - `https://familienarchiv.example.com/api/actuator/health` returns `{"status":"UP"}`. - `http://familienarchiv.example.com` redirects to `https://`. - Port `8080` and `3000` are not directly accessible from outside the Docker network.
marcel added the devopsphase-3: prod-compose labels 2026-03-28 10:46:42 +01:00
Sign in to join this conversation.
1 Participants
Notifications
Due Date
No due date set.
Dependencies

No dependencies set.

Reference: marcel/familienarchiv#136