feat(deploy): add Docker Compose and Traefik edge stack

Provide shared, development, and production compose overlays with Traefik routing.
This commit is contained in:
ahp
2026-10-07 19:40:26 +03:30
parent 0958bd365b
commit dbd9a4ddb0
5 changed files with 447 additions and 0 deletions
+96
View File
@@ -0,0 +1,96 @@
# Local / staging-on-laptop overlay (Traefik + published data ports + mild limits).
# Usage: see compose.yaml header or scripts/compose-dev.sh
services:
postgres:
ports:
- "127.0.0.1:5432:5432"
deploy:
resources:
limits:
memory: 512M
redis:
ports:
- "127.0.0.1:6379:6379"
deploy:
resources:
limits:
memory: 256M
rustfs:
environment:
RUSTFS_CONSOLE_ENABLE: "true"
ports:
- "127.0.0.1:9000:9000"
- "127.0.0.1:9001:9001"
deploy:
resources:
limits:
memory: 512M
backend:
environment:
DEV_MODE: ${DEV_MODE:-false}
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
deploy:
resources:
limits:
memory: 512M
frontend:
deploy:
resources:
limits:
memory: 512M
traefik:
# v3.6.1+ required for Docker Engine 29+ API negotiation
image: traefik@sha256:24841fe2de7304c149343d877d2923b4c8800a38ba015dea9174c23b20e344a0
restart: unless-stopped
security_opt:
- no-new-privileges:true
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=pargar_pargar
- --providers.file.directory=/etc/traefik/dynamic
- --providers.file.watch=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
# Dev: Traefik default certificate (browser warning expected on localhost).
- --api.dashboard=true
- --api.insecure=true
- --log.level=INFO
- --accesslog=true
- --ping=true
- --global.checkNewVersion=false
ports:
- "80:80"
- "443:443"
# Dashboard only on loopback
- "127.0.0.1:8088:8080"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../traefik/dynamic:/etc/traefik/dynamic:ro
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 10s
timeout: 3s
retries: 10
deploy:
resources:
limits:
memory: 256M
networks:
- pargar
+112
View File
@@ -0,0 +1,112 @@
# Internet-facing production overlay (Traefik + Let's Encrypt, no host DB ports).
# Usage:
# docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.prod.yaml \
# --env-file .env.prod up -d --build
#
# Requires: DOMAIN (public DNS), ACME_EMAIL, PUBLIC_URL=https://$DOMAIN, strong secrets.
services:
postgres:
# No host ports — only reachable on the compose network.
deploy:
resources:
limits:
memory: 1G
redis:
deploy:
resources:
limits:
memory: 512M
rustfs:
environment:
RUSTFS_CONSOLE_ENABLE: "false"
deploy:
resources:
limits:
memory: 1G
backend:
environment:
DEV_MODE: "false"
SEED_CURRICULUM: "false"
labels:
- traefik.http.routers.pargar-api.tls.certresolver=le
- traefik.http.routers.pargar-cert-api.tls.certresolver=le
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 256M
frontend:
labels:
- traefik.http.routers.pargar-web.tls.certresolver=le
- traefik.http.routers.pargar-cert-web.tls.certresolver=le
deploy:
resources:
limits:
memory: 1G
reservations:
memory: 256M
traefik:
# v3.6.1+ required for Docker Engine 29+ API negotiation
image: traefik@sha256:24841fe2de7304c149343d877d2923b4c8800a38ba015dea9174c23b20e344a0
restart: unless-stopped
security_opt:
- no-new-privileges:true
command:
- --providers.docker=true
- --providers.docker.exposedbydefault=false
- --providers.docker.network=pargar_pargar
- --providers.file.directory=/etc/traefik/dynamic
- --providers.file.watch=true
- --entrypoints.web.address=:80
- --entrypoints.websecure.address=:443
- --entrypoints.web.http.redirections.entrypoint.to=websecure
- --entrypoints.web.http.redirections.entrypoint.scheme=https
- --entrypoints.web.http.redirections.entrypoint.permanent=true
- --entrypoints.web.http.aliasHeadersStrategy=delete
- --entrypoints.websecure.http.aliasHeadersStrategy=delete
- --certificatesresolvers.le.acme.email=${ACME_EMAIL:?ACME_EMAIL must be set}
- --certificatesresolvers.le.acme.storage=/data/acme.json
- --certificatesresolvers.le.acme.httpchallenge=true
- --certificatesresolvers.le.acme.httpchallenge.entrypoint=web
- --api.dashboard=false
- --log.level=WARN
- --accesslog=true
- --ping=true
- --global.checkNewVersion=false
- --serversTransport.forwardingTimeouts.dialTimeout=10s
- --serversTransport.forwardingTimeouts.responseHeaderTimeout=60s
ports:
- "80:80"
- "[::]:80:80"
- "443:443"
- "[::]:443:443"
volumes:
- /var/run/docker.sock:/var/run/docker.sock:ro
- ../traefik/dynamic:/etc/traefik/dynamic:ro
- traefik_data:/data
depends_on:
backend:
condition: service_healthy
frontend:
condition: service_healthy
healthcheck:
test: ["CMD", "traefik", "healthcheck", "--ping"]
interval: 10s
timeout: 3s
retries: 10
deploy:
resources:
limits:
memory: 256M
networks:
- pargar
volumes:
traefik_data:
+183
View File
@@ -0,0 +1,183 @@
# Shared services — always combine with compose.dev.yaml or compose.prod.yaml:
#
# Dev: docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.dev.yaml up -d --build
# Prod: docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.prod.yaml --env-file .env.prod up -d --build
#
# Or use scripts/compose-dev.sh / scripts/compose-prod.sh / root docker-compose.yml (dev default).
name: pargar
x-backend-env: &backend-env
PORT: "8080"
DATABASE_URL: postgres://${POSTGRES_USER:-pargar}:${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}@postgres:5432/${POSTGRES_DB:-pargar}?sslmode=disable
REDIS_URL: redis://:${REDIS_PASSWORD:?REDIS_PASSWORD must be set}@redis:6379
JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must be set}
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL must be set}
FRONTEND_ORIGINS: ${FRONTEND_ORIGINS:?FRONTEND_ORIGINS must be set}
METRICS_TOKEN: ${METRICS_TOKEN:-}
TRUSTED_PROXY_CIDRS: ${TRUSTED_PROXY_CIDRS:-}
STORAGE_DRIVER: s3
STORAGE_ENDPOINT: http://rustfs:9000
STORAGE_REGION: ${STORAGE_REGION:-us-east-1}
STORAGE_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY must be set}
STORAGE_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
STORAGE_BUCKET: ${STORAGE_BUCKET:-pargar}
SEED_CURRICULUM: ${SEED_CURRICULUM:-false}
ADMIN_NAME: ${ADMIN_NAME:-Admin}
ADMIN_USERNAME: ${ADMIN_USERNAME:-admin}
ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@example.com}
ADMIN_PASSWORD: ${ADMIN_PASSWORD:?ADMIN_PASSWORD must be set}
OTEL_EXPORTER_OTLP_ENDPOINT: ${OTEL_EXPORTER_OTLP_ENDPOINT:-}
OTEL_SERVICE_NAME: ${OTEL_SERVICE_NAME:-pargar-api}
DONATION_ENABLED: ${DONATION_ENABLED:-true}
DONATION_NOTE: ${DONATION_NOTE:-}
PHYSICAL_CERT_ENABLED: ${PHYSICAL_CERT_ENABLED:-true}
PHYSICAL_CERT_PRICE_IRR: ${PHYSICAL_CERT_PRICE_IRR:-2500000}
PHYSICAL_CERT_WINDOW_DAYS: ${PHYSICAL_CERT_WINDOW_DAYS:-30}
services:
postgres:
image: postgres:16.4-alpine
restart: unless-stopped
environment:
POSTGRES_USER: ${POSTGRES_USER:-pargar}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set}
POSTGRES_DB: ${POSTGRES_DB:-pargar}
volumes:
- pgdata:/var/lib/postgresql/data
healthcheck:
test: ["CMD-SHELL", "pg_isready -U \"$$POSTGRES_USER\" -d \"$$POSTGRES_DB\""]
interval: 5s
timeout: 3s
retries: 20
networks:
- pargar
redis:
image: redis@sha256:858f009f9709ce576febc734aa78b8f6d624b82571f9ddb6bda4377c833b3499
restart: unless-stopped
command: ["redis-server", "--requirepass", "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}", "--appendonly", "yes"]
volumes:
- redis_data:/data
healthcheck:
test: ["CMD", "redis-cli", "-a", "${REDIS_PASSWORD:?REDIS_PASSWORD must be set}", "ping"]
interval: 5s
timeout: 3s
retries: 20
networks:
- pargar
rustfs:
image: rustfs/rustfs@sha256:1803faef57627e2d9c2e7d89d655d712ddded5389040054987163043fecb6a3c
restart: unless-stopped
command: /data
environment:
RUSTFS_ACCESS_KEY: ${RUSTFS_ACCESS_KEY:?RUSTFS_ACCESS_KEY must be set}
RUSTFS_SECRET_KEY: ${RUSTFS_SECRET_KEY:?RUSTFS_SECRET_KEY must be set}
RUSTFS_ADDRESS: ":9000"
RUSTFS_CONSOLE_ADDRESS: ":9001"
RUSTFS_CONSOLE_ENABLE: ${RUSTFS_CONSOLE_ENABLE:-false}
volumes:
- rustfs_data:/data
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://localhost:9000/health"]
interval: 5s
timeout: 3s
retries: 20
start_period: 10s
networks:
- pargar
backend:
build:
context: ../../backend
dockerfile: Dockerfile
image: ${BACKEND_IMAGE:-pargar-backend:local}
restart: unless-stopped
environment:
<<: *backend-env
DEV_MODE: ${DEV_MODE:-false}
depends_on:
postgres:
condition: service_healthy
redis:
condition: service_healthy
rustfs:
condition: service_healthy
expose:
- "8080"
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:8080/api/health"]
interval: 5s
timeout: 3s
retries: 20
networks:
- pargar
labels:
- traefik.enable=true
- traefik.docker.network=pargar_pargar
# API + media (higher priority than frontend catch-all)
- traefik.http.routers.pargar-api.rule=Host(`${DOMAIN:-localhost}`) && (PathPrefix(`/api`) || PathPrefix(`/media`))
- traefik.http.routers.pargar-api.entrypoints=websecure
- traefik.http.routers.pargar-api.priority=100
- traefik.http.routers.pargar-api.tls=true
- traefik.http.routers.pargar-api.middlewares=security-headers@file,compress@file
- traefik.http.routers.pargar-api.service=pargar-api
- traefik.http.services.pargar-api.loadbalancer.server.port=8080
# Certificate public API — noindex
- traefik.http.routers.pargar-cert-api.rule=Host(`${DOMAIN:-localhost}`) && PathPrefix(`/api/certificates`)
- traefik.http.routers.pargar-cert-api.entrypoints=websecure
- traefik.http.routers.pargar-cert-api.priority=110
- traefik.http.routers.pargar-cert-api.tls=true
- traefik.http.routers.pargar-cert-api.middlewares=security-headers@file,compress@file,noindex@file
- traefik.http.routers.pargar-cert-api.service=pargar-api
frontend:
build:
context: ../../frontend
dockerfile: Dockerfile
args:
NEXT_PUBLIC_API_URL: ""
NEXT_PUBLIC_WS_URL: ""
image: ${FRONTEND_IMAGE:-pargar-frontend:local}
restart: unless-stopped
environment:
NODE_ENV: production
depends_on:
backend:
condition: service_healthy
expose:
- "3000"
healthcheck:
test: ["CMD", "wget", "--spider", "--quiet", "http://127.0.0.1:3000"]
interval: 10s
timeout: 3s
retries: 10
networks:
- pargar
labels:
- traefik.enable=true
- traefik.docker.network=pargar_pargar
- traefik.http.routers.pargar-web.rule=Host(`${DOMAIN:-localhost}`)
- traefik.http.routers.pargar-web.entrypoints=websecure
- traefik.http.routers.pargar-web.priority=1
- traefik.http.routers.pargar-web.tls=true
- traefik.http.routers.pargar-web.middlewares=security-headers@file,compress@file
- traefik.http.routers.pargar-web.service=pargar-web
- traefik.http.services.pargar-web.loadbalancer.server.port=3000
# Unlisted certificate pages
- traefik.http.routers.pargar-cert-web.rule=Host(`${DOMAIN:-localhost}`) && PathPrefix(`/c`)
- traefik.http.routers.pargar-cert-web.entrypoints=websecure
- traefik.http.routers.pargar-cert-web.priority=50
- traefik.http.routers.pargar-cert-web.tls=true
- traefik.http.routers.pargar-cert-web.middlewares=security-headers@file,compress@file,noindex@file
- traefik.http.routers.pargar-cert-web.service=pargar-web
networks:
pargar:
name: pargar_pargar
volumes:
pgdata:
redis_data:
rustfs_data:
+45
View File
@@ -0,0 +1,45 @@
# Shared Traefik middlewares (file provider).
# Applied via Docker labels / IngressRoute references.
http:
middlewares:
redirect-https:
redirectScheme:
scheme: https
permanent: true
security-headers:
headers:
stsSeconds: 31536000
stsIncludeSubdomains: true
stsPreload: false
contentTypeNosniff: true
browserXssFilter: true
frameDeny: true
referrerPolicy: strict-origin-when-cross-origin
permissionsPolicy: "camera=(), microphone=(), geolocation=()"
customResponseHeaders:
Server: ""
Content-Security-Policy: >-
default-src 'self'; base-uri 'self'; form-action 'self';
frame-ancestors 'none'; object-src 'none';
script-src 'self' 'unsafe-inline'; style-src 'self' 'unsafe-inline';
img-src 'self' data: blob:; font-src 'self' data:;
media-src 'self' blob:; connect-src 'self' ws: wss:;
worker-src 'self' blob:
compress:
compress: {}
noindex:
headers:
customResponseHeaders:
X-Robots-Tag: "noindex, nofollow, noarchive"
# Strip client-supplied forwarding headers before Traefik re-adds trusted ones.
# Backend trusts X-Forwarded-* only from TRUSTED_PROXY_CIDRS (Docker/k8s nets).
strip-forwarded:
headers:
customRequestHeaders:
X-Real-IP: ""
X-Forwarded-For: ""
+11
View File
@@ -0,0 +1,11 @@
# Default local stack = base + Traefik (dev).
# Prefer explicit scripts for clarity:
# ./scripts/compose-dev.sh up -d --build
# ./scripts/compose-prod.sh up -d --build
#
# Equivalent:
# docker compose -f deploy/compose/compose.yaml -f deploy/compose/compose.dev.yaml up -d --build
include:
- path: deploy/compose/compose.yaml
- path: deploy/compose/compose.dev.yaml