Files
ERP/docker-compose.yml
T
MMD 0ea0322967 Revert 253c922: restore the PM/RBAC work to the integration
Reverting the production revert so the pm branch can merge cleanly on
top of the restored RBAC base. Conflicts against newer integration work
resolved by combining:
- App.tsx: v7's bot-draft wizard bypass + login returnUrl kept alongside
  the supervisor gating (no-project block screen, panel scoping)
- LoginPage: returnUrl navigation kept, supervisor routing added
- migration.sql: bot_drafts table kept, unified-projects ALTERs restored
2026-10-01 15:24:56 +03:30

369 lines
14 KiB
YAML

services:
postgres:
image: postgres:18
container_name: procurement-postgres
environment:
POSTGRES_DB: procurement
POSTGRES_USER: postgres
# Local dev default; production overrides via .env (required by the
# prod overlay) — never commit real passwords.
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-postgres}
ports:
- "5455:5432"
networks:
- default
- shared # monitoring stack scrapes it (postgres-exporter)
volumes:
- postgres_data:/var/lib/postgresql
- ./sql/migration.sql:/docker-entrypoint-initdb.d/01-migration.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"]
interval: 5s
timeout: 5s
retries: 10
restart: unless-stopped
mem_limit: 1g
# ==========================================================================
# 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
mem_limit: 128m
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
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
ports:
- "9000:9000" # S3 API
- "9001:9001" # Web console
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
mem_limit: 512m
# MinIO Client (mc): waits for the server, creates the bucket, exits.
# Use for ad-hoc ops too: docker compose run --rm minio-init ...
minio-init:
image: minio/mc:latest
container_name: procurement-minio-init
depends_on:
minio:
condition: service_started
environment:
MINIO_ENDPOINT: minio:9000
MINIO_ROOT_USER: ${MINIO_ROOT_USER:-minioadmin}
MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-minioadmin}
MINIO_BUCKET: procurement-files
entrypoint: >
/bin/sh -c "
until mc alias set local http://$${MINIO_ENDPOINT} $${MINIO_ROOT_USER} $${MINIO_ROOT_PASSWORD}; do
echo 'waiting for minio server...'; sleep 2;
done;
mc mb --ignore-existing local/$${MINIO_BUCKET};
echo bucket $${MINIO_BUCKET} ready;
"
restart: "no"
# ==========================================================================
# Scheduled logical backups of BOTH databases (procurement + casdoor).
# Writes pg_dump -Fc archives to a bind mount (default ./backups, point
# BACKUP_DIR at /srv/erp/backups on the VPS) and prunes archives older
# than BACKUP_KEEP_DAYS. Runs on the same postgres:18 image already pulled.
# ==========================================================================
db-backup:
image: postgres:18
container_name: procurement-db-backup
depends_on:
postgres:
condition: service_healthy
environment:
PGPASSWORD: ${POSTGRES_PASSWORD:-postgres}
BACKUP_INTERVAL_SECONDS: ${BACKUP_INTERVAL_SECONDS:-86400}
BACKUP_KEEP_DAYS: ${BACKUP_KEEP_DAYS:-14}
volumes:
- ${BACKUP_DIR:-./backups}:/backups
entrypoint: >
/bin/sh -c "
echo 'db-backup: dumping every $${BACKUP_INTERVAL_SECONDS}s, keeping $${BACKUP_KEEP_DAYS} days';
while true; do
STAMP=$$(date +%Y%m%d-%H%M%S);
pg_dump -h postgres -U postgres -d procurement -Fc -f /backups/procurement-$${STAMP}.dump &&
echo \"db-backup: procurement -> /backups/procurement-$${STAMP}.dump\";
pg_dump -h postgres -U postgres -d casdoor -Fc -f /backups/casdoor-$${STAMP}.dump &&
echo \"db-backup: casdoor -> /backups/casdoor-$${STAMP}.dump\";
find /backups -name '*.dump' -mtime +$${BACKUP_KEEP_DAYS} -delete;
sleep $${BACKUP_INTERVAL_SECONDS};
done
"
restart: unless-stopped
mem_limit: 256m
# ==========================================================================
# v2.6 — Casdoor (OIDC identity provider)
# Console: http://localhost:8080 (admin / 123)
# Application "inventory-app" (org "inventory", client inventory-app-client)
# 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:
image: casbin/casdoor:latest
container_name: procurement-casdoor
depends_on:
postgres:
condition: service_healthy
environment:
RUNNING_IN_DOCKER: "true"
driverName: postgres
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
mem_limit: 768m
api:
image: v15-api:latest
build:
context: ./backend
dockerfile: Dockerfile
container_name: procurement-api
environment:
PROCUREMENT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_MINIO_ENDPOINT: minio:9000
PROCUREMENT_MINIO_ACCESS_KEY: ${MINIO_ROOT_USER:-minioadmin}
PROCUREMENT_MINIO_SECRET_KEY: ${MINIO_ROOT_PASSWORD:-minioadmin}
PROCUREMENT_MINIO_BUCKET: procurement-files
PROCUREMENT_MINIO_SECURE: "false"
# "dev" enables the local defaults; "prod" turns on the fail-fast
# startup checks (no wildcard CORS, no default credentials).
PROCUREMENT_ENVIRONMENT: ${ENVIRONMENT:-dev}
PROCUREMENT_DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Comma-separated browser origins (JSON-free .env friendly).
PROCUREMENT_CORS_ORIGINS: ${CORS_ORIGINS:-http://localhost,http://localhost:3000,http://localhost:5173}
# Casdoor: endpoint is the in-network service URL; the issuer must match
# the origin Casdoor advertises to browsers (its OIDC discovery doc).
PROCUREMENT_CASDOOR_ENDPOINT: http://casdoor:8000
PROCUREMENT_CASDOOR_ISSUER: ${CASDOOR_ISSUER:-http://localhost:8080}
PROCUREMENT_CASDOOR_CLIENT_ID: inventory-app-client
PROCUREMENT_CASDOOR_CLIENT_SECRET: ${CASDOOR_CLIENT_SECRET:-inventory-app-secret-2026}
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: ${CASDOOR_ADMIN_PASSWORD:-123}
# Observability (Phase 2) — see monitoring/README.md
PROCUREMENT_LOG_FORMAT: ${LOG_FORMAT:-text}
PROCUREMENT_SENTRY_DSN: ${SENTRY_DSN_BACKEND:-}
PROCUREMENT_OPENPANEL_API_URL: ${OPENPANEL_API_URL:-}
PROCUREMENT_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
PROCUREMENT_OPENPANEL_CLIENT_SECRET: ${OPENPANEL_CLIENT_SECRET:-}
ports:
- "8000:8000"
volumes:
- ./backend/app:/srv/backend/app
networks:
- traefik_network
- default
- shared # monitoring stack scrapes api:8000/metrics
healthcheck:
test: ["CMD-SHELL", "python -c \"import urllib.request as u; u.urlopen('http://127.0.0.1:8000/health', timeout=3)\""]
interval: 30s
timeout: 5s
retries: 5
start_period: 40s
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
depends_on:
postgres:
condition: service_healthy
minio-init:
condition: service_completed_successfully
restart: unless-stopped
mem_limit: 768m
frontend:
image: v15-frontend:latest
build:
context: ./frontend
args:
# Developer Mode panel on the login page — dev-only aid. Production
# builds MUST leave this false (identity comes from Casdoor tokens).
DEV_LOGIN_ENABLED: ${DEV_LOGIN_ENABLED:-false}
# Registry overridable for networks where npmmirror is unreachable.
NPM_REGISTRY: ${NPM_REGISTRY:-https://registry.npmmirror.com}
# Observability (build-time baked; empty = disabled)
VITE_OPENPANEL_URL: ${OPENPANEL_PUBLIC_URL:-}
VITE_OPENPANEL_CLIENT_ID: ${OPENPANEL_CLIENT_ID:-}
VITE_SENTRY_DSN: ${SENTRY_DSN_FRONTEND:-}
container_name: procurement-frontend
# از entrypoint پیش‌فرض image nginx رد می‌شویم: آن اسکریپت `apk manifest`
# اجرا می‌کند و اگر دسترسی به مخزن بسته‌ها نباشد بی‌نهایت معلق می‌ماند
# (سرویس بالا نمی‌آید و traefik خطای 502 می‌دهد). کانفیگ ما مستقل است و
# نیازی به آن اسکریپت‌ها ندارد.
entrypoint: ["nginx", "-g", "daemon off;"]
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
mem_limit: 128m
bot:
image: v15-bot:latest
build:
context: ./bot
dockerfile: Dockerfile
container_name: procurement-bot
volumes:
- ./bot/app:/srv/bot/app
environment:
PROCUREMENT_BOT_BOT_TOKEN: ${BALE_BOT_TOKEN:-1354444845:yhVMnKZBFuKaU3UW5fP1bHmknEHx99mipWw}
PROCUREMENT_BOT_BALE_API_BASE: ${BALE_API_BASE:-https://tapi.bale.ai}
PROCUREMENT_BOT_API_BASE_URL: http://api:8000/api/v1
PROCUREMENT_BOT_DATABASE_URL: postgresql+asyncpg://postgres:${POSTGRES_PASSWORD:-postgres}@postgres:5432/procurement
PROCUREMENT_BOT_AI_BASE_URL: ${AI_BASE_URL:-https://api.deepseek.com}
PROCUREMENT_BOT_AI_API_KEY: ${AI_API_KEY:-sk-a671eab15be846d494bf0839070a3b32}
PROCUREMENT_BOT_LLM_MODEL: ${AI_LLM_MODEL:-deepseek-flash}
PROCUREMENT_BOT_ADMIN_CHAT_IDS: ${BALE_ADMIN_CHAT_IDS:-1461166742,1839839922}
PROCUREMENT_BOT_SERVICE_TOKEN: ${BALE_SERVICE_TOKEN:-}
PROCUREMENT_BOT_FRONTEND_URL: ${APP_HOST:-http://localhost}
depends_on:
postgres:
condition: service_healthy
api:
condition: service_started
networks:
- default
restart: unless-stopped
mem_limit: 512m
networks:
# شبکه‌ی عمومی: فقط ترافیک و سرویس‌های عمومی. postgres روی شبکه‌ی
# پیش‌فرض می‌ماند و از بیرون قابل دسترسی نیست.
traefik_network:
name: traefik_network
driver: bridge
# Fixed-name network shared with the monitoring stack (monitoring compose
# joins it as external) — carries /metrics scrapes and postgres-exporter.
shared:
name: erp_shared
driver: bridge
volumes:
postgres_data:
minio_data:
traefik_certs: