From dbd9a4ddb0296f13b46c491fe8846ddc3066e50f Mon Sep 17 00:00:00 2001 From: Amir Husayn Panahifar Date: Wed, 7 Oct 2026 19:40:26 +0330 Subject: [PATCH] feat(deploy): add Docker Compose and Traefik edge stack Provide shared, development, and production compose overlays with Traefik routing. --- deploy/compose/compose.dev.yaml | 96 +++++++++++++ deploy/compose/compose.prod.yaml | 112 +++++++++++++++ deploy/compose/compose.yaml | 183 +++++++++++++++++++++++++ deploy/traefik/dynamic/middlewares.yml | 45 ++++++ docker-compose.yml | 11 ++ 5 files changed, 447 insertions(+) create mode 100644 deploy/compose/compose.dev.yaml create mode 100644 deploy/compose/compose.prod.yaml create mode 100644 deploy/compose/compose.yaml create mode 100644 deploy/traefik/dynamic/middlewares.yml create mode 100644 docker-compose.yml diff --git a/deploy/compose/compose.dev.yaml b/deploy/compose/compose.dev.yaml new file mode 100644 index 0000000..a3ec447 --- /dev/null +++ b/deploy/compose/compose.dev.yaml @@ -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 diff --git a/deploy/compose/compose.prod.yaml b/deploy/compose/compose.prod.yaml new file mode 100644 index 0000000..a3c60c2 --- /dev/null +++ b/deploy/compose/compose.prod.yaml @@ -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: diff --git a/deploy/compose/compose.yaml b/deploy/compose/compose.yaml new file mode 100644 index 0000000..8a1ac32 --- /dev/null +++ b/deploy/compose/compose.yaml @@ -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: diff --git a/deploy/traefik/dynamic/middlewares.yml b/deploy/traefik/dynamic/middlewares.yml new file mode 100644 index 0000000..600d67a --- /dev/null +++ b/deploy/traefik/dynamic/middlewares.yml @@ -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: "" diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..ece90a7 --- /dev/null +++ b/docker-compose.yml @@ -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