From 960b007ba79b6b50e9a68dc40eccaa95b04ac773 Mon Sep 17 00:00:00 2001 From: Davide Grilli Date: Tue, 25 Aug 2026 11:42:24 +0200 Subject: [PATCH] Add Docker Compose stack with PostgreSQL and Caddy Production traffic enters through Caddy, which terminates TLS for the public domain and the CMS subdomain and sets HSTS and the other security headers. PostgreSQL, Strapi and Nuxt publish no ports of their own. docker-compose.dev.yml publishes the ports on localhost and drops Caddy for local testing. It is a separate file rather than an override.yml so it can only be applied when passed explicitly, never by accident in production. --- .env.example | 28 +++++++++++++++ caddy/Caddyfile | 29 ++++++++++++++++ docker-compose.dev.yml | 20 +++++++++++ docker-compose.yml | 78 ++++++++++++++++++++++++++++++++++++++++++ 4 files changed, 155 insertions(+) create mode 100644 .env.example create mode 100644 caddy/Caddyfile create mode 100644 docker-compose.dev.yml create mode 100644 docker-compose.yml diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..2257648 --- /dev/null +++ b/.env.example @@ -0,0 +1,28 @@ +# Copy to .env and replace every placeholder. Never commit .env. + +# --- Domains (Caddy) --- +PUBLIC_DOMAIN=blog.localhost +CMS_DOMAIN=cms.blog.localhost +ACME_EMAIL=admin@example.com + +# --- Database --- +POSTGRES_DB=blog +POSTGRES_USER=blog +POSTGRES_PASSWORD=change-me + +# --- Strapi --- +# Generate each secret with: openssl rand -base64 32 +APP_KEYS=change-me-1,change-me-2 +API_TOKEN_SALT=change-me +ADMIN_JWT_SECRET=change-me +TRANSFER_TOKEN_SALT=change-me +JWT_SECRET=change-me +ENCRYPTION_KEY=change-me + +# --- Frontend --- +# Server-side only: internal Docker address of Strapi. +STRAPI_URL=http://cms:1337 +# Public base URL of the website, used for canonical URLs and Open Graph. +PUBLIC_SITE_URL=https://blog.localhost +# Public base URL of Strapi, used to build absolute media URLs in the browser. +PUBLIC_STRAPI_URL=https://cms.blog.localhost diff --git a/caddy/Caddyfile b/caddy/Caddyfile new file mode 100644 index 0000000..a9ff3a4 --- /dev/null +++ b/caddy/Caddyfile @@ -0,0 +1,29 @@ +{ + email {$ACME_EMAIL} +} + +(security_headers) { + header { + Strict-Transport-Security "max-age=31536000; includeSubDomains" + X-Content-Type-Options "nosniff" + X-Frame-Options "SAMEORIGIN" + Referrer-Policy "strict-origin-when-cross-origin" + -Server + } +} + +{$PUBLIC_DOMAIN} { + import security_headers + encode zstd gzip + reverse_proxy frontend:3000 +} + +{$CMS_DOMAIN} { + import security_headers + encode zstd gzip + # Uploaded media can be large; Strapi's own limit still applies. + request_body { + max_size 100MB + } + reverse_proxy cms:1337 +} diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml new file mode 100644 index 0000000..53b5b03 --- /dev/null +++ b/docker-compose.dev.yml @@ -0,0 +1,20 @@ +# Local testing without domains or TLS: publishes the app ports on localhost and +# leaves Caddy out. Never used in production — it must be passed explicitly: +# docker compose -f docker-compose.yml -f docker-compose.dev.yml up -d --build cms frontend +services: + database: + ports: + - "127.0.0.1:5432:5432" + + cms: + ports: + - "127.0.0.1:1337:1337" + environment: + STRAPI_URL: http://localhost:1337 + + frontend: + ports: + - "127.0.0.1:3000:3000" + environment: + NUXT_PUBLIC_SITE_URL: http://localhost:3000 + NUXT_PUBLIC_STRAPI_URL: http://localhost:1337 diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..cb680b4 --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,78 @@ +services: + database: + image: postgres:17-alpine + restart: unless-stopped + environment: + POSTGRES_DB: ${POSTGRES_DB} + POSTGRES_USER: ${POSTGRES_USER} + POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} + volumes: + - postgres-data:/var/lib/postgresql/data + healthcheck: + test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER} -d ${POSTGRES_DB}"] + interval: 10s + timeout: 5s + retries: 5 + + cms: + build: ./cms + restart: unless-stopped + depends_on: + database: + condition: service_healthy + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 1337 + DATABASE_CLIENT: postgres + DATABASE_HOST: database + DATABASE_PORT: 5432 + DATABASE_NAME: ${POSTGRES_DB} + DATABASE_USERNAME: ${POSTGRES_USER} + DATABASE_PASSWORD: ${POSTGRES_PASSWORD} + APP_KEYS: ${APP_KEYS} + API_TOKEN_SALT: ${API_TOKEN_SALT} + ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET} + TRANSFER_TOKEN_SALT: ${TRANSFER_TOKEN_SALT} + JWT_SECRET: ${JWT_SECRET} + ENCRYPTION_KEY: ${ENCRYPTION_KEY} + STRAPI_URL: https://${CMS_DOMAIN} + volumes: + - cms-uploads:/app/public/uploads + + frontend: + build: ./frontend + restart: unless-stopped + depends_on: + - cms + environment: + NODE_ENV: production + HOST: 0.0.0.0 + PORT: 3000 + NUXT_STRAPI_URL: ${STRAPI_URL} + NUXT_PUBLIC_SITE_URL: ${PUBLIC_SITE_URL} + NUXT_PUBLIC_STRAPI_URL: ${PUBLIC_STRAPI_URL} + + caddy: + image: caddy:2-alpine + restart: unless-stopped + depends_on: + - frontend + - cms + ports: + - "80:80" + - "443:443" + environment: + PUBLIC_DOMAIN: ${PUBLIC_DOMAIN} + CMS_DOMAIN: ${CMS_DOMAIN} + ACME_EMAIL: ${ACME_EMAIL} + volumes: + - ./caddy/Caddyfile:/etc/caddy/Caddyfile:ro + - caddy-data:/data + - caddy-config:/config + +volumes: + postgres-data: + cms-uploads: + caddy-data: + caddy-config: