Files
training-software/docker-compose.prod.yml
Paperclip CTO e92ae6c136 feat(TRA-362): add docker-compose.prod.yml with nginx as web server
Introduces a production Docker Compose stack that places nginx in front
of gunicorn. nginx serves the frontend SPA and Django static files
directly, and proxies all backend routes (/api, /admin, /healthz, etc.)
to the Django container. SECURE_SSL_REDIRECT is now env-configurable so
plain-HTTP nginx deployments work without separate settings files.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-05-08 08:08:52 +02:00

152 lines
5.1 KiB
YAML

version: "3.9"
# Production deployment with nginx as the public-facing web server.
# nginx terminates HTTP on port 80, serves static files and the frontend SPA
# directly, and proxies all backend routes to gunicorn.
#
# Quick start:
# cp .env.example .env.prod # fill in all values
# docker compose -f docker-compose.prod.yml up -d --build
# docker compose -f docker-compose.prod.yml exec web python manage.py migrate
# docker compose -f docker-compose.prod.yml exec web python manage.py createsuperuser
#
# For HTTPS: terminate TLS at nginx (add ssl_certificate / ssl_certificate_key
# directives to nginx/nginx.conf and map port 443) or put a reverse proxy such
# as Traefik in front and remove the port 80 mapping here.
services:
db:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: training
POSTGRES_USER: training
POSTGRES_PASSWORD: ${DB_PASSWORD:?DB_PASSWORD is required}
volumes:
- postgres_data:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U training"]
interval: 5s
timeout: 5s
retries: 10
networks:
- internal
redis:
image: redis:7-alpine
restart: unless-stopped
healthcheck:
test: ["CMD", "redis-cli", "ping"]
interval: 5s
timeout: 3s
retries: 10
networks:
- internal
web:
image: ghcr.io/paperclip-training/training-software:${IMAGE_TAG:-latest}
build:
context: .
restart: unless-stopped
command: gunicorn config.wsgi:application --bind 0.0.0.0:8000 --workers 4 --timeout 60
volumes:
- staticfiles:/app/staticfiles
environment:
DJANGO_SETTINGS_MODULE: config.settings.prod
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY:?DJANGO_SECRET_KEY is required}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS:?DJANGO_ALLOWED_HOSTS is required}
DATABASE_URL: "postgres://training:${DB_PASSWORD}@db:5432/training"
REDIS_URL: "redis://redis:6379/0"
# Set to false when nginx handles plain HTTP (no upstream HTTPS proxy).
# Set to true (or remove) when TLS is terminated at nginx or an upstream proxy.
SECURE_SSL_REDIRECT: ${SECURE_SSL_REDIRECT:-false}
OIDC_RP_CLIENT_ID: ${OIDC_RP_CLIENT_ID:-}
OIDC_RP_CLIENT_SECRET: ${OIDC_RP_CLIENT_SECRET:-}
OIDC_OP_AUTHORIZATION_ENDPOINT: ${OIDC_OP_AUTHORIZATION_ENDPOINT:-}
OIDC_OP_TOKEN_ENDPOINT: ${OIDC_OP_TOKEN_ENDPOINT:-}
OIDC_OP_USER_ENDPOINT: ${OIDC_OP_USER_ENDPOINT:-}
OIDC_OP_JWKS_ENDPOINT: ${OIDC_OP_JWKS_ENDPOINT:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal
celery:
image: ghcr.io/paperclip-training/training-software:${IMAGE_TAG:-latest}
build:
context: .
restart: unless-stopped
command: celery -A config worker --loglevel=info
environment:
DJANGO_SETTINGS_MODULE: config.settings.prod
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DATABASE_URL: "postgres://training:${DB_PASSWORD}@db:5432/training"
REDIS_URL: "redis://redis:6379/0"
SECURE_SSL_REDIRECT: "false"
OIDC_RP_CLIENT_ID: ${OIDC_RP_CLIENT_ID:-}
OIDC_RP_CLIENT_SECRET: ${OIDC_RP_CLIENT_SECRET:-}
OIDC_OP_AUTHORIZATION_ENDPOINT: ${OIDC_OP_AUTHORIZATION_ENDPOINT:-}
OIDC_OP_TOKEN_ENDPOINT: ${OIDC_OP_TOKEN_ENDPOINT:-}
OIDC_OP_USER_ENDPOINT: ${OIDC_OP_USER_ENDPOINT:-}
OIDC_OP_JWKS_ENDPOINT: ${OIDC_OP_JWKS_ENDPOINT:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal
celery-beat:
image: ghcr.io/paperclip-training/training-software:${IMAGE_TAG:-latest}
build:
context: .
restart: unless-stopped
command: celery -A config beat --loglevel=info --scheduler django_celery_beat.schedulers:DatabaseScheduler
environment:
DJANGO_SETTINGS_MODULE: config.settings.prod
DJANGO_SECRET_KEY: ${DJANGO_SECRET_KEY}
DJANGO_ALLOWED_HOSTS: ${DJANGO_ALLOWED_HOSTS}
DATABASE_URL: "postgres://training:${DB_PASSWORD}@db:5432/training"
REDIS_URL: "redis://redis:6379/0"
SECURE_SSL_REDIRECT: "false"
OIDC_RP_CLIENT_ID: ${OIDC_RP_CLIENT_ID:-}
OIDC_RP_CLIENT_SECRET: ${OIDC_RP_CLIENT_SECRET:-}
OIDC_OP_AUTHORIZATION_ENDPOINT: ${OIDC_OP_AUTHORIZATION_ENDPOINT:-}
OIDC_OP_TOKEN_ENDPOINT: ${OIDC_OP_TOKEN_ENDPOINT:-}
OIDC_OP_USER_ENDPOINT: ${OIDC_OP_USER_ENDPOINT:-}
OIDC_OP_JWKS_ENDPOINT: ${OIDC_OP_JWKS_ENDPOINT:-}
depends_on:
db:
condition: service_healthy
redis:
condition: service_healthy
networks:
- internal
nginx:
image: nginx:1.27-alpine
restart: unless-stopped
ports:
- "${HTTP_PORT:-80}:80"
volumes:
- ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro
- ./frontend/public:/usr/share/nginx/html:ro
- staticfiles:/app/staticfiles:ro
depends_on:
- web
networks:
- internal
volumes:
postgres_data:
staticfiles:
networks:
internal:
driver: bridge