name: vignette-prod # NAS 운영 전용 완결 스택이다. infra/docker-compose.yml의 로컬 개발 스택과 합치지 않는다. # 외부 볼륨은 백업/격리 복구/휴먼 게이트를 통과한 뒤 운영자가 미리 생성해야 한다. x-api-image: &api-image image: ${VIGNETTE_NAS_API_IMAGE:?Immutable NAS API image reference is required} x-web-image: &web-image image: ${VIGNETTE_NAS_WEB_IMAGE:?Immutable NAS web image reference is required} x-production-labels: &production-labels com.vignette.owner: vignette com.vignette.runtime-class: production com.vignette.ssot-host: nas com.vignette.stack: vignette-prod services: db: image: ${VIGNETTE_NAS_POSTGRES_IMAGE:?Immutable NAS PostgreSQL image ID is required} restart: unless-stopped stop_grace_period: 1m environment: POSTGRES_USER: ${POSTGRES_USER:-vignette_owner} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?NAS PostgreSQL owner password is required} POSTGRES_DB: ${POSTGRES_DB:-vignette} APP_DB_USER: ${APP_DB_USER:-vignette_app} APP_DB_PASSWORD: ${APP_DB_PASSWORD:?NAS application DB password is required} volumes: - type: volume source: pgdata target: /var/lib/postgresql/data - type: bind source: ./db/init target: /docker-entrypoint-initdb.d read_only: true healthcheck: test: - CMD-SHELL - >- PGPASSWORD=$${APP_DB_PASSWORD} psql -U $${APP_DB_USER} -d $${POSTGRES_DB} -tAc "SELECT to_regclass('app.ci_regression_dag_node') IS NOT NULL" | grep -qx t interval: 10s timeout: 5s retries: 12 start_period: 5m labels: <<: *production-labels com.vignette.data-role: database-ssot networks: [backend] engine: <<: *api-image command: - python - -m - uvicorn - engine_gateway.gateway:app - --host - 0.0.0.0 - --port - "9099" restart: unless-stopped stop_grace_period: 30s environment: OPENAI_API_KEY: ${OPENAI_API_KEY:?OpenAI key is required for the NAS engine} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} OPENAI_ENGINE_MODEL: ${OPENAI_ENGINE_MODEL:?An approved OpenAI engine model is required} OPENAI_ENGINE_MODELS: ${OPENAI_ENGINE_MODELS:-gpt-5.6-terra,gpt-5.6-luna,gpt-4.1} ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required} ENGINE_CAPABILITY_CACHE_TTL_SECONDS: ${ENGINE_CAPABILITY_CACHE_TTL_SECONDS:-1800} ENGINE_READY_TTL_SECONDS: ${ENGINE_READY_TTL_SECONDS:-1800} ENGINE_READY_FAILURE_TTL_SECONDS: ${ENGINE_READY_FAILURE_TTL_SECONDS:-30} ENGINE_READY_TIMEOUT_SECONDS: ${ENGINE_READY_TIMEOUT_SECONDS:-45} ENGINE_GENERATE_TIMEOUT_SECONDS: ${ENGINE_GENERATE_TIMEOUT_SECONDS:-300} expose: - "9099" read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=128m cap_drop: [ALL] security_opt: - no-new-privileges:true healthcheck: test: - CMD - python - -c - >- import json, urllib.request; payload=json.load(urllib.request.urlopen('http://localhost:9099/health')); assert payload.get('ok') is True interval: 15s timeout: 5s retries: 8 start_period: 30s labels: <<: *production-labels com.vignette.data-role: engine-ssot networks: [backend, app-egress] api: <<: *api-image restart: unless-stopped stop_grace_period: 1m # NAS 호스트 터널만 접근할 수 있도록 loopback에만 고정 바인딩한다. ports: - "127.0.0.1:18080:8000" depends_on: db: { condition: service_healthy } engine: { condition: service_healthy } environment: ENVIRONMENT: prod DATABASE_URL: postgresql://${APP_DB_USER:-vignette_app}:${APP_DB_PASSWORD:?NAS application DB password is required}@db:5432/${POSTGRES_DB:-vignette} ENGINE_URL: http://engine:9099 ENGINE_MODE: openai VIGNETTE_LIVE_CLIENT_PROVIDER: openai ENGINE_GATEWAY_SHARED_SECRET: ${ENGINE_GATEWAY_SHARED_SECRET:?Gateway shared secret is required} OPENAI_API_KEY: ${OPENAI_API_KEY:?OpenAI key is required for production voice and engine paths} OPENAI_BASE_URL: ${OPENAI_BASE_URL:-https://api.openai.com/v1} VIGNETTE_VOICE_STT_PROVIDER: openai VIGNETTE_VOICE_TTS_PROVIDER: openai SESSION_SECRET: ${SESSION_SECRET:?Production session secret is required} VIGNETTE_RUPTURE_INTERNAL_TOKEN: ${VIGNETTE_RUPTURE_INTERNAL_TOKEN:?G3 token is required} VIGNETTE_PRACTICE_INTERNAL_TOKEN: ${VIGNETTE_PRACTICE_INTERNAL_TOKEN:?G4 token is required} VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN: ${VIGNETTE_CALIBRATION_TRANSFER_INTERNAL_TOKEN:?G5 token is required} VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN: ${VIGNETTE_SUPERVISION_RESEARCH_INTERNAL_TOKEN:?G6 token is required} VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN: ${VIGNETTE_MULTIMODAL_ALLIANCE_INTERNAL_TOKEN:?G7 token is required} VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_INTERNAL_TOKEN:?G8 token is required} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENABLED:-false} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_INTERVAL_SECONDS:-3600} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_STARTUP_DELAY_SECONDS:-30} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_RETRY_DELAY_SECONDS:-300} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_LEASE_TIMEOUT_SECONDS:-1800} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_ENGINE_TIMEOUT_SECONDS:-300} VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_PRODUCER_BATCH_SIZE:-1} VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED: ${VIGNETTE_CONTINUOUS_IMPROVEMENT_DRIFT_TRIGGER_ENABLED:-false} VIGNETTE_CONTINUOUS_IMPROVEMENT_ROLLBACK_EXECUTOR_ENABLED: "false" OAUTH_GOOGLE_CLIENT_ID: ${OAUTH_GOOGLE_CLIENT_ID:?Google OAuth client id is required} OAUTH_GOOGLE_CLIENT_SECRET: ${OAUTH_GOOGLE_CLIENT_SECRET:?Google OAuth client secret is required} OAUTH_REDIRECT_URI: ${OAUTH_REDIRECT_URI:-https://api-vignette.chanpaca.net/auth/callback} AUTH_ALLOWED_EMAIL_DOMAINS: '${AUTH_ALLOWED_EMAIL_DOMAINS:-["hs.ac.kr","twentyoz.kr"]}' AUTH_TEACHER_EMAILS: '${AUTH_TEACHER_EMAILS:-[]}' AUTH_ADMIN_EMAILS: '${AUTH_ADMIN_EMAILS:-[]}' AUTH_SUPER_ADMIN_EMAILS: '${AUTH_SUPER_ADMIN_EMAILS:?Super admin allowlist is required}' AUTH_APPROVED_EMAILS: '${AUTH_APPROVED_EMAILS:-[]}' AUTH_NEW_USER_DEFAULT_STATUS: pending AUTH_DEV_LOGIN_ENABLED: "false" AUTH_DEV_LOGIN_EXTRA_ORIGINS: '[]' DEFAULT_AFFILIATION: ${DEFAULT_AFFILIATION:-} AUTO_SEED_PERSONAS: "false" ALLOW_SEED_PERSONA_FALLBACK: "false" EVALUATOR_GOLDEN_FEWSHOT_ENABLED: ${EVALUATOR_GOLDEN_FEWSHOT_ENABLED:-false} FRONTEND_BASE_URL: https://vignette.chanpaca.net FRONTEND_ORIGIN_MAP: '${FRONTEND_ORIGIN_MAP:?Set FRONTEND_ORIGIN_MAP in the NAS secret environment}' CORS_ORIGINS: '${CORS_ORIGINS:-["https://vignette.chanpaca.net","https://vignette-b1q.pages.dev"]}' USER_UPLOAD_DIR: /app/uploads NOTIFICATION_EMAIL_PROVIDER: ${NOTIFICATION_EMAIL_PROVIDER:-disabled} NOTIFICATION_EMAIL_MAX_ATTEMPTS: ${NOTIFICATION_EMAIL_MAX_ATTEMPTS:-3} NOTIFICATION_EMAIL_RETRY_SECONDS: ${NOTIFICATION_EMAIL_RETRY_SECONDS:-900} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Vignette} SMTP_STARTTLS: ${SMTP_STARTTLS:-true} SMTP_SSL: ${SMTP_SSL:-false} VIGNETTE_RUNTIME_CLASS: production VIGNETTE_SSOT_HOST: nas VIGNETTE_NAS_PGDATA_VOLUME: ${VIGNETTE_NAS_PGDATA_VOLUME:?Explicit NAS PostgreSQL volume name is required} VIGNETTE_NAS_APIUPLOADS_VOLUME: ${VIGNETTE_NAS_APIUPLOADS_VOLUME:?Explicit NAS uploads volume name is required} volumes: - type: volume source: apiuploads target: /app/uploads volume: nocopy: true read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=128m cap_drop: [ALL] security_opt: - no-new-privileges:true healthcheck: test: - CMD - python - -c - >- import json, urllib.request; payload=json.load(urllib.request.urlopen('http://localhost:8000/health')); assert payload.get('status') == 'ok' and payload.get('db') is True and payload.get('engine') is True interval: 30s timeout: 10s retries: 8 start_period: 2m labels: <<: *production-labels com.vignette.data-role: api-ssot networks: [backend, app-egress] web: <<: *web-image restart: unless-stopped depends_on: api: { condition: service_healthy } read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=16m # nginx needs writable temporary paths while the root filesystem remains read-only. - /var/cache/nginx:rw,noexec,nosuid,size=16m - /var/run:rw,noexec,nosuid,size=1m cap_drop: [ALL] cap_add: - NET_BIND_SERVICE - CHOWN - SETUID - SETGID security_opt: - no-new-privileges:true labels: <<: *production-labels com.vignette.data-role: static-web-replica networks: [backend] private-proxy: image: caddy@sha256:5f5c8640aae01df9654968d946d8f1a56c497f1dd5c5cda4cf95ab7c14d58648 restart: unless-stopped depends_on: web: { condition: service_started } api: { condition: service_healthy } ports: - "${VIGNETTE_NAS_PRIVATE_BIND_ADDRESS:?Explicit NAS Tailnet or private bind address is required}:${VIGNETTE_NAS_PRIVATE_HTTP_PORT:-18088}:80" volumes: - type: bind source: ./Caddyfile.nas target: /etc/caddy/Caddyfile read_only: true read_only: true tmpfs: - /data:rw,noexec,nosuid,size=16m - /config:rw,noexec,nosuid,size=16m - /tmp:rw,noexec,nosuid,size=16m cap_drop: [ALL] cap_add: - NET_BIND_SERVICE security_opt: - no-new-privileges:true labels: <<: *production-labels com.vignette.data-role: tailnet-private-validation networks: [backend] cloudflared: image: cloudflare/cloudflared@sha256:d9ff527dc19799e24d3689058161fb01e752a24be1823577bae54c9c2489275f profiles: [public-cutover-gate] command: - tunnel - --no-autoupdate - --config - /etc/cloudflared/config.yml - run restart: unless-stopped depends_on: api: { condition: service_healthy } volumes: - type: bind source: ${VIGNETTE_TUNNEL_CONFIG_PATH:?Rendered cloudflared config path is required} target: /etc/cloudflared/config.yml read_only: true - type: volume source: cloudflaredsecrets target: /run/secrets read_only: true volume: nocopy: true read_only: true tmpfs: - /tmp:rw,noexec,nosuid,size=32m cap_drop: [ALL] security_opt: - no-new-privileges:true labels: <<: *production-labels com.vignette.data-role: gated-public-edge # API는 NAS loopback에만 열리고, 터널은 호스트 네트워크로 이를 호출한다. # Docker 재시작 뒤에도 restart 정책으로 공개 연결을 자동 복구한다. network_mode: host volumes: pgdata: external: true name: ${VIGNETTE_NAS_PGDATA_VOLUME:?Explicit NAS PostgreSQL volume name is required} apiuploads: external: true name: ${VIGNETTE_NAS_APIUPLOADS_VOLUME:?Explicit NAS uploads volume name is required} cloudflaredsecrets: external: true name: ${VIGNETTE_NAS_CLOUDFLARED_SECRETS_VOLUME:?Explicit NAS cloudflared secrets volume name is required} networks: backend: name: vignette-prod-backend driver: bridge internal: true labels: <<: *production-labels app-egress: name: vignette-prod-app-egress driver: bridge labels: <<: *production-labels tunnel-egress: name: vignette-prod-tunnel-egress driver: bridge labels: <<: *production-labels