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: training-software:local build: context: . restart: unless-stopped env_file: - .env 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: training-software:local build: context: . restart: unless-stopped env_file: - .env 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: training-software:local build: context: . restart: unless-stopped env_file: - .env 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 environment: FRONTEND_API_BASE_URL: ${FRONTEND_API_BASE_URL:-http://localhost:8000} ports: - "${HTTP_PORT:-80}:80" volumes: - ./nginx/nginx.conf:/etc/nginx/conf.d/default.conf:ro - ./frontend/public:/usr/share/nginx/html - ./frontend/docker-entrypoint/40-generate-config.sh:/docker-entrypoint.d/40-generate-config.sh:ro - staticfiles:/app/staticfiles:ro depends_on: - web networks: - internal volumes: postgres_data: staticfiles: networks: internal: driver: bridge