diff --git a/docker-compose.prod.yml b/docker-compose.prod.yml new file mode 100644 index 0000000..93c2a46 --- /dev/null +++ b/docker-compose.prod.yml @@ -0,0 +1,74 @@ +# ============================================================================ +# Production overlay — real domains + HTTPS (Let's Encrypt) + HTTP→HTTPS +# redirect. Usage: +# +# APP_HOST=app.example.com API_HOST=api.example.com AUTH_HOST=auth.example.com \ +# S3_HOST=s3.example.com STORAGE_HOST=storage.example.com \ +# TRAEFIK_ACME_EMAIL=admin@example.com \ +# docker compose -f docker-compose.yml -f docker-compose.prod.yml up -d --build +# +# Requirements: DNS records for every host pointing at the server, ports +# 80 + 443 open (ACME http-challenge uses port 80), and the api's +# PROCUREMENT_CASDOOR_ISSUER must be updated to the public auth host +# (see docs/casdoor-auth.md). +# ============================================================================ + +services: + traefik: + environment: + # Certificates persist across restarts (acme.json in the traefik_certs volume). + TRAEFIK_ACME_EMAIL: ${TRAEFIK_ACME_EMAIL:?set TRAEFIK_ACME_EMAIL for production} + + api: + ports: [] # production: only reachable through Traefik + labels: + - traefik.http.routers.api.rule=Host(`${API_HOST:?set API_HOST}`) + - traefik.http.routers.api.entrypoints=websecure + - traefik.http.routers.api.tls=true + - traefik.http.routers.api.tls.certresolver=letsencrypt + # HTTP → HTTPS + - traefik.http.routers.api-redir.rule=Host(`${API_HOST:?set API_HOST}`) + - traefik.http.routers.api-redir.entrypoints=web + - traefik.http.routers.api-redir.middlewares=redirect-https@file + + casdoor: + ports: [] # production: only reachable through Traefik (auth host) + environment: + # Casdoor must know its public origin so tokens carry the right iss. + origin: "https://${AUTH_HOST:?set AUTH_HOST}" + labels: + - traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`) + - traefik.http.routers.casdoor.entrypoints=websecure + - traefik.http.routers.casdoor.tls=true + - traefik.http.routers.casdoor.tls.certresolver=letsencrypt + - traefik.http.routers.casdoor-redir.rule=Host(`${AUTH_HOST:?set AUTH_HOST}`) + - traefik.http.routers.casdoor-redir.entrypoints=web + - traefik.http.routers.casdoor-redir.middlewares=redirect-https@file + + frontend: + labels: + - traefik.http.routers.frontend.rule=Host(`${APP_HOST:?set APP_HOST}`) + - traefik.http.routers.frontend.entrypoints=websecure + - traefik.http.routers.frontend.tls=true + - traefik.http.routers.frontend.tls.certresolver=letsencrypt + - traefik.http.routers.frontend-redir.rule=Host(`${APP_HOST:?set APP_HOST}`) + - traefik.http.routers.frontend-redir.entrypoints=web + - traefik.http.routers.frontend-redir.middlewares=redirect-https@file + + minio: + ports: [] # production: only reachable through Traefik + labels: + - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:?set S3_HOST}`) + - traefik.http.routers.minio-api.entrypoints=websecure + - traefik.http.routers.minio-api.tls=true + - traefik.http.routers.minio-api.tls.certresolver=letsencrypt + - traefik.http.routers.minio-api-redir.rule=Host(`${S3_HOST:?set S3_HOST}`) + - traefik.http.routers.minio-api-redir.entrypoints=web + - traefik.http.routers.minio-api-redir.middlewares=redirect-https@file + - traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) + - traefik.http.routers.minio-console.entrypoints=websecure + - traefik.http.routers.minio-console.tls=true + - traefik.http.routers.minio-console.tls.certresolver=letsencrypt + - traefik.http.routers.minio-console-redir.rule=Host(`${STORAGE_HOST:?set STORAGE_HOST}`) + - traefik.http.routers.minio-console-redir.entrypoints=web + - traefik.http.routers.minio-console-redir.middlewares=redirect-https@file diff --git a/docker-compose.yml b/docker-compose.yml index 38d7be5..af5bd7e 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -14,7 +14,7 @@ services: volumes: - postgres_data:/var/lib/postgresql - ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.sql:ro - # - ./sql/seed.sql:/docker-entrypoint-initdb.d/02-seed.sql:ro + - ./sql/02-casdoor-db.sql:/docker-entrypoint-initdb.d/02-casdoor-db.sql:ro healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -d procurement"] @@ -24,6 +24,44 @@ services: restart: unless-stopped + # ========================================================================== + # Traefik v3 — public reverse proxy (replaces nginx as the edge) + # App is served on http://localhost (port 80) instead of :3000. + # Production: see docker-compose.prod.yml (real domains + HTTPS via + # Let's Encrypt, HTTP→HTTPS redirect). + # ========================================================================== + traefik: + # v3.6+ required: Traefik ≤3.3 fails against Docker Engine 29.x + # ("Error response from daemon" — Docker API version mismatch). + image: traefik:v3.6 + container_name: procurement-traefik + restart: unless-stopped + command: + - --providers.docker=true + - --providers.docker.exposedbydefault=false + # Containers are on several networks; Traefik must use the dedicated + # proxy network IP (otherwise it may pick the default-network IP → 504). + - --providers.docker.network=traefik_network + - --providers.file.filename=/etc/traefik/dynamic.yml + - --entrypoints.web.address=:80 + - --entrypoints.websecure.address=:443 + - --certificatesresolvers.letsencrypt.acme.email=${TRAEFIK_ACME_EMAIL:-admin@example.com} + - --certificatesresolvers.letsencrypt.acme.storage=/certs/acme.json + - --certificatesresolvers.letsencrypt.acme.httpchallenge=true + - --certificatesresolvers.letsencrypt.acme.httpchallenge.entrypoint=web + - --log.level=INFO + ports: + - "80:80" + - "443:443" + volumes: + # NOTE: mounted read-write — Docker Desktop rejects read-only + # /var/run/docker.sock mounts ("Error response from daemon"). + - /var/run/docker.sock:/var/run/docker.sock + - ./infra/traefik-dynamic.yml:/etc/traefik/dynamic.yml:ro + - traefik_certs:/certs + networks: + - traefik_network + minio: image: minio/minio:latest container_name: procurement-minio @@ -41,6 +79,23 @@ services: volumes: - minio_data:/data + labels: + - traefik.enable=true + # S3 API + - traefik.http.routers.minio-api.rule=Host(`${S3_HOST:-s3.localhost}`) + - traefik.http.routers.minio-api.entrypoints=web + - traefik.http.routers.minio-api.service=minio-api-svc + - traefik.http.services.minio-api-svc.loadbalancer.server.port=9000 + # Web console + - traefik.http.routers.minio-console.rule=Host(`${STORAGE_HOST:-storage.localhost}`) + - traefik.http.routers.minio-console.entrypoints=web + - traefik.http.routers.minio-console.service=minio-console-svc + - traefik.http.services.minio-console-svc.loadbalancer.server.port=9001 + + networks: + - traefik_network + - default + restart: unless-stopped # MinIO Client (mc): waits for the server, creates the bucket, exits. @@ -77,31 +132,35 @@ services: # is created by backend/tools/seed_casdoor.py — run it once after the # first boot: # docker compose run --rm -e CASDOOR_BASE=http://casdoor:8000 api python tools/seed_casdoor.py + # + # Casdoor uses the SAME postgres instance as the app (database `casdoor`, + # created by sql/02-casdoor-db.sql on a fresh volume). # ========================================================================== - casdoor-postgres: - image: postgres:16 - container_name: procurement-casdoor-postgres - environment: - POSTGRES_DB: casdoor - POSTGRES_USER: casdoor - POSTGRES_PASSWORD: casdoor - volumes: - - casdoor_postgres_data:/var/lib/postgresql - restart: unless-stopped - casdoor: image: casbin/casdoor:latest container_name: procurement-casdoor depends_on: - - casdoor-postgres + postgres: + condition: service_healthy environment: RUNNING_IN_DOCKER: "true" driverName: postgres - dataSourceName: "user=casdoor password=casdoor host=casdoor-postgres port=5432 sslmode=disable dbname=casdoor" + dataSourceName: "user=casdoor password=casdoor host=postgres port=5432 sslmode=disable dbname=casdoor" dbName: casdoor origin: "http://localhost:8080" ports: - "8080:8000" + + labels: + - traefik.enable=true + - traefik.http.routers.casdoor.rule=Host(`${AUTH_HOST:-auth.localhost}`) + - traefik.http.routers.casdoor.entrypoints=web + - traefik.http.services.casdoor.loadbalancer.server.port=8000 + + networks: + - traefik_network + - default + restart: unless-stopped api: @@ -126,10 +185,23 @@ services: PROCUREMENT_CASDOOR_ORGANIZATION: inventory PROCUREMENT_CASDOOR_APP_NAME: inventory-app PROCUREMENT_CASDOOR_AUDIENCE: inventory-app-client + # Console admin used by POST /users/sync (identity mirroring). + PROCUREMENT_CASDOOR_ADMIN_USER: admin + PROCUREMENT_CASDOOR_ADMIN_PASSWORD: "123" ports: - "8000:8000" + labels: + - traefik.enable=true + - traefik.http.routers.api.rule=Host(`${API_HOST:-api.localhost}`) + - traefik.http.routers.api.entrypoints=web + - traefik.http.services.api.loadbalancer.server.port=8000 + + networks: + - traefik_network + - default + depends_on: postgres: condition: service_healthy @@ -146,15 +218,29 @@ services: DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-true} container_name: procurement-frontend - ports: - - "3000:80" + labels: + - traefik.enable=true + - traefik.http.routers.frontend.rule=Host(`${APP_HOST:-localhost}`) + - traefik.http.routers.frontend.entrypoints=web + - traefik.http.services.frontend.loadbalancer.server.port=80 + + networks: + - traefik_network + - default depends_on: - api restart: unless-stopped +networks: + # شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی + # پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست. + traefik_network: + name: traefik_network + driver: bridge + volumes: postgres_data: minio_data: - casdoor_postgres_data: + traefik_certs: diff --git a/infra/traefik-dynamic.yml b/infra/traefik-dynamic.yml new file mode 100644 index 0000000..0119500 --- /dev/null +++ b/infra/traefik-dynamic.yml @@ -0,0 +1,9 @@ +# Traefik dynamic config (file provider) — shared middlewares. +# The HTTP→HTTPS redirect is attached from docker-compose.prod.yml so that +# local development (http://localhost) keeps working without TLS. +http: + middlewares: + redirect-https: + redirectScheme: + scheme: https + permanent: true diff --git a/sql/02-casdoor-db.sql b/sql/02-casdoor-db.sql new file mode 100644 index 0000000..ce6e4d3 --- /dev/null +++ b/sql/02-casdoor-db.sql @@ -0,0 +1,9 @@ +-- ============================================================ +-- 02-casdoor-db.sql — creates the Casdoor role + database on the +-- unified PostgreSQL instance (runs only on a fresh postgres volume, +-- after 01-migration.sql). The Casdoor service connects here instead +-- of running its own postgres container (v1.3 DB consolidation). +-- ============================================================ + +CREATE ROLE casdoor LOGIN PASSWORD 'casdoor'; +CREATE DATABASE casdoor OWNER casdoor; diff --git a/start.ps1 b/start.ps1 index cca184a..5ee1bc8 100644 --- a/start.ps1 +++ b/start.ps1 @@ -25,8 +25,9 @@ Start-Sleep -Seconds 5 docker compose ps Write-Host "" -Write-Host "Frontend : http://localhost:3000" -ForegroundColor Green +Write-Host "Frontend : http://localhost (via Traefik; old :3000 is gone)" -ForegroundColor Green Write-Host "API docs : http://localhost:8000/docs" -ForegroundColor Green -Write-Host "DB : localhost:5432 (procurement / postgres / postgres)" -ForegroundColor Green +Write-Host "Casdoor : http://localhost:8080 (admin / 123)" -ForegroundColor Green +Write-Host "DB : localhost:5433 (procurement / postgres / postgres; casdoor DB inside the same instance)" -ForegroundColor Green Write-Host "" Write-Host "Stop: docker compose down | Full reset: docker compose down -v && up -d --build" -ForegroundColor DarkGray