# Disclosure Bureau — full deployment stack. # Routed via the host's existing plegal-traefik (network: traefik-public). # Internal services share the disclosure-internal network and are NOT exposed # to the host. Public services (web, kong, studio, search) get Traefik labels. name: disclosure networks: internal: name: disclosure-internal driver: bridge traefik: name: traefik-public external: true volumes: db-data: storage-data: meili-data: hf-cache: glitchtip-redis-data: glitchtip-uploads: forgejo-data: forgejo-runner-config: services: # ─── Database ───────────────────────────────────────────────────────────── db: container_name: disclosure-db image: supabase/postgres:15.8.1.060 restart: unless-stopped networks: [internal] healthcheck: test: ["CMD-SHELL", "pg_isready -U postgres -h localhost"] interval: 10s timeout: 5s retries: 12 environment: POSTGRES_HOST: /var/run/postgresql POSTGRES_PORT: 5432 POSTGRES_DB: postgres POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} PGPASSWORD: ${POSTGRES_PASSWORD} JWT_SECRET: ${JWT_SECRET} JWT_EXP: 3600 POSTGRES_INITDB_ARGS: "--data-checksums" command: - postgres - -c - shared_buffers=${POSTGRES_SHARED_BUFFERS:-384MB} - -c - work_mem=${POSTGRES_WORK_MEM:-12MB} - -c - maintenance_work_mem=${POSTGRES_MAINTENANCE_WORK_MEM:-96MB} - -c - max_connections=${POSTGRES_MAX_CONNECTIONS:-80} volumes: - db-data:/var/lib/postgresql/data # ─── Auth (GoTrue) ──────────────────────────────────────────────────────── auth: container_name: disclosure-auth image: supabase/gotrue:v2.170.0 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } environment: GOTRUE_API_HOST: 0.0.0.0 GOTRUE_API_PORT: 9999 API_EXTERNAL_URL: https://${DOMAIN_API} GOTRUE_DB_DRIVER: postgres GOTRUE_DB_DATABASE_URL: postgres://supabase_auth_admin:${POSTGRES_PASSWORD}@db:5432/postgres?search_path=auth GOTRUE_SITE_URL: https://${DOMAIN_MAIN} # Explicit external URL so confirmation links land on the public site # (Next.js /auth/callback), not on the Kong gateway host. GOTRUE_API_EXTERNAL_URL: https://${DOMAIN_MAIN} GOTRUE_URI_ALLOW_LIST: https://${DOMAIN_MAIN},https://www.${DOMAIN_MAIN} GOTRUE_DISABLE_SIGNUP: "false" GOTRUE_JWT_ADMIN_ROLES: service_role GOTRUE_JWT_AUD: authenticated GOTRUE_JWT_DEFAULT_GROUP_NAME: authenticated GOTRUE_JWT_EXP: 3600 GOTRUE_JWT_SECRET: ${JWT_SECRET} GOTRUE_EXTERNAL_EMAIL_ENABLED: "true" # SMTP configured (Spacemail) → email confirmation required for signups. GOTRUE_MAILER_AUTOCONFIRM: "false" GOTRUE_MAILER_OTP_EXP: 3600 GOTRUE_SMTP_HOST: ${SMTP_HOST} GOTRUE_SMTP_PORT: ${SMTP_PORT} GOTRUE_SMTP_USER: ${SMTP_USER} GOTRUE_SMTP_PASS: ${SMTP_PASS} GOTRUE_SMTP_ADMIN_EMAIL: ${SMTP_FROM} GOTRUE_SMTP_SENDER_NAME: ${SMTP_FROM_NAME} GOTRUE_MAILER_URLPATHS_INVITE: /auth/callback GOTRUE_MAILER_URLPATHS_CONFIRMATION: /auth/callback GOTRUE_MAILER_URLPATHS_RECOVERY: /auth/callback GOTRUE_MAILER_URLPATHS_EMAIL_CHANGE: /auth/callback # ─── PostgREST ──────────────────────────────────────────────────────────── rest: container_name: disclosure-rest image: postgrest/postgrest:v12.2.8 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } environment: PGRST_DB_URI: postgres://authenticator:${POSTGRES_PASSWORD}@db:5432/postgres PGRST_DB_SCHEMAS: public,storage PGRST_DB_ANON_ROLE: anon PGRST_JWT_SECRET: ${JWT_SECRET} PGRST_DB_USE_LEGACY_GUCS: "false" PGRST_APP_SETTINGS_JWT_SECRET: ${JWT_SECRET} PGRST_APP_SETTINGS_JWT_EXP: 3600 # ─── Realtime ───────────────────────────────────────────────────────────── realtime: container_name: disclosure-realtime image: supabase/realtime:v2.30.34 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } environment: PORT: 4000 DB_HOST: db DB_PORT: 5432 DB_USER: supabase_admin DB_PASSWORD: ${POSTGRES_PASSWORD} DB_NAME: postgres DB_ENC_KEY: ${VAULT_ENC_KEY} API_JWT_SECRET: ${JWT_SECRET} SECRET_KEY_BASE: ${SECRET_KEY_BASE} ERL_AFLAGS: -proto_dist inet_tcp DNS_NODES: "''" RLIMIT_NOFILE: "10000" APP_NAME: realtime SEED_SELF_HOST: "true" RUN_JANITOR: "true" # ─── Storage ────────────────────────────────────────────────────────────── storage: container_name: disclosure-storage image: supabase/storage-api:v1.14.3 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } rest: { condition: service_started } imgproxy: { condition: service_started } environment: ANON_KEY: ${ANON_KEY} SERVICE_KEY: ${SERVICE_ROLE_KEY} POSTGREST_URL: http://rest:3000 PGRST_JWT_SECRET: ${JWT_SECRET} DATABASE_URL: postgres://supabase_storage_admin:${POSTGRES_PASSWORD}@db:5432/postgres FILE_SIZE_LIMIT: 52428800 STORAGE_BACKEND: file FILE_STORAGE_BACKEND_PATH: /var/lib/storage TENANT_ID: stub REGION: stub GLOBAL_S3_BUCKET: stub ENABLE_IMAGE_TRANSFORMATION: "true" IMGPROXY_URL: http://imgproxy:5001 volumes: - storage-data:/var/lib/storage imgproxy: container_name: disclosure-imgproxy image: darthsim/imgproxy:v3.8.0 restart: unless-stopped networks: [internal] environment: IMGPROXY_BIND: ":5001" # W0-F2: tighten filesystem root from "/" (whole VPS) to the Storage # backend mount only. Imgproxy never reads outside Storage objects. IMGPROXY_LOCAL_FILESYSTEM_ROOT: /var/lib/storage IMGPROXY_USE_ETAG: "true" IMGPROXY_ENABLE_WEBP_DETECTION: "true" volumes: - storage-data:/var/lib/storage # ─── pg-meta + Studio ───────────────────────────────────────────────────── meta: container_name: disclosure-meta image: supabase/postgres-meta:v0.83.2 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } environment: PG_META_PORT: 8080 PG_META_DB_HOST: db PG_META_DB_PORT: 5432 PG_META_DB_NAME: postgres PG_META_DB_USER: supabase_admin PG_META_DB_PASSWORD: ${POSTGRES_PASSWORD} studio: container_name: disclosure-studio image: supabase/studio:20241202-71e5240 restart: unless-stopped networks: [internal, traefik] depends_on: meta: { condition: service_started } environment: # W1: Next.js standalone server binds to the container hostname by # default, leaving 127.0.0.1 unreachable — the Docker healthcheck # (fetch 127.0.0.1:3000/api/profile) then loops on ECONNREFUSED and # the service never goes healthy. HOSTNAME=0.0.0.0 forces it to bind # on all interfaces so both the loopback and the docker IP respond. HOSTNAME: 0.0.0.0 STUDIO_PG_META_URL: http://meta:8080 POSTGRES_PASSWORD: ${POSTGRES_PASSWORD} DEFAULT_ORGANIZATION_NAME: "Disclosure Bureau" DEFAULT_PROJECT_NAME: "disclosure" SUPABASE_URL: http://kong:8000 SUPABASE_PUBLIC_URL: https://${DOMAIN_API} SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} AUTH_JWT_SECRET: ${JWT_SECRET} DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-studio.rule=Host(`${DOMAIN_STUDIO}`) - traefik.http.routers.disclosure-studio.entrypoints=websecure - traefik.http.routers.disclosure-studio.tls=true - traefik.http.routers.disclosure-studio.tls.certresolver=letsencrypt - traefik.http.services.disclosure-studio.loadbalancer.server.port=3000 # W0-F3: real basic auth (was effectively disabled with usersfile=/dev/null). # The user/password is DASHBOARD_USERNAME / DASHBOARD_PASSWORD from .env; # the bcrypt hash below was generated with $$ doubled for compose escaping. # Rotate by regenerating: htpasswd -nbB (then double every $). - traefik.http.middlewares.disclosure-studio-auth.basicauth.users=admin:$$2b$$05$$tFLAMGNWX7xDbVyQ/O0G1.ruLwm3Le1.ErgdUTB9IYeJeH2FHd4ha - traefik.http.routers.disclosure-studio.middlewares=disclosure-studio-auth@docker # ─── Kong API gateway ───────────────────────────────────────────────────── kong: container_name: disclosure-kong image: kong:2.8.1 restart: unless-stopped networks: [internal, traefik] depends_on: auth: { condition: service_started } rest: { condition: service_started } realtime: { condition: service_started } storage: { condition: service_started } environment: KONG_DATABASE: "off" # Read rendered config (envsubst happens at startup, see entrypoint below) KONG_DECLARATIVE_CONFIG: /tmp/kong.yml KONG_DNS_ORDER: LAST,A,CNAME KONG_PLUGINS: request-transformer,cors,key-auth,acl,basic-auth KONG_NGINX_PROXY_PROXY_BUFFER_SIZE: 160k KONG_NGINX_PROXY_PROXY_BUFFERS: 64 160k SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_KEY: ${SERVICE_ROLE_KEY} DASHBOARD_USERNAME: ${DASHBOARD_USERNAME} DASHBOARD_PASSWORD: ${DASHBOARD_PASSWORD} # Kong declarative config does NOT do env substitution by itself. We # render the template into /tmp/kong.yml at container start so the JWT # keys land literally in the config Kong actually loads. user: root entrypoint: - /bin/sh - -c - | apk add --no-cache gettext >/dev/null 2>&1 || true envsubst < /usr/local/kong/kong.yml.tmpl > /tmp/kong.yml chown kong:kong /tmp/kong.yml exec /docker-entrypoint.sh kong docker-start volumes: - ./kong.yml:/usr/local/kong/kong.yml.tmpl:ro labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-api.rule=Host(`${DOMAIN_API}`) - traefik.http.routers.disclosure-api.entrypoints=websecure - traefik.http.routers.disclosure-api.tls=true - traefik.http.routers.disclosure-api.tls.certresolver=letsencrypt - traefik.http.services.disclosure-api.loadbalancer.server.port=8000 # ─── Meilisearch ────────────────────────────────────────────────────────── meilisearch: container_name: disclosure-meili image: getmeili/meilisearch:v1.10 restart: unless-stopped networks: [internal, traefik] environment: MEILI_MASTER_KEY: ${MEILI_MASTER_KEY} MEILI_NO_ANALYTICS: "true" MEILI_ENV: production MEILI_MAX_INDEXING_MEMORY: ${MEILI_MAX_INDEXING_MEMORY:-512MB} volumes: - meili-data:/meili_data labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-search.rule=Host(`${DOMAIN_SEARCH}`) - traefik.http.routers.disclosure-search.entrypoints=websecure - traefik.http.routers.disclosure-search.tls=true - traefik.http.routers.disclosure-search.tls.certresolver=letsencrypt - traefik.http.services.disclosure-search.loadbalancer.server.port=7700 # ─── Next.js web (Disclosure Bureau frontend) ───────────────────────────── web: container_name: disclosure-web build: context: /data/disclosure/web # rsynced from laptop, see scripts/sync-data.sh dockerfile: Dockerfile args: NEXT_PUBLIC_SUPABASE_URL: https://${DOMAIN_API} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} NEXT_PUBLIC_SITE_URL: https://${DOMAIN_MAIN} restart: unless-stopped networks: [internal, traefik] depends_on: kong: { condition: service_started } environment: NODE_ENV: production NODE_OPTIONS: ${NEXT_NODE_OPTIONS:---max-old-space-size=768} NEXT_PUBLIC_SUPABASE_URL: https://${DOMAIN_API} NEXT_PUBLIC_SUPABASE_ANON_KEY: ${ANON_KEY} SUPABASE_SERVICE_ROLE_KEY: ${SERVICE_ROLE_KEY} NEXT_PUBLIC_SITE_URL: https://${DOMAIN_MAIN} UFO_ROOT: /data/ufo # W1-TD#10: bump pg pool from default 5 to 20 (chat agent + hybrid_search # can saturate the smaller pool under concurrent load). PG_POOL_MAX: ${PG_POOL_MAX:-20} # Chat agent (W1-F8: CLAUDE_CODE_OAUTH_TOKEN only injected when the # provider actually uses it — default provider is openrouter, so the token # stays absent from this container's env unless CHAT_PROVIDER=claude-code). CLAUDE_CODE_OAUTH_TOKEN: ${CLAUDE_CODE_OAUTH_TOKEN_FOR_WEB:-} CLAUDE_CODE_MODEL: ${CLAUDE_CODE_MODEL} OPENROUTER_API_KEY: ${OPENROUTER_API_KEY} OPENROUTER_MODEL: ${OPENROUTER_MODEL} OPENROUTER_FALLBACK_MODEL: ${OPENROUTER_FALLBACK_MODEL} CHAT_PROVIDER: ${CHAT_PROVIDER} # Meilisearch (used by /api/search) MEILISEARCH_URL: http://meilisearch:7700 MEILISEARCH_API_KEY: ${MEILI_MASTER_KEY} # Embed service (used by /lib/retrieval) EMBED_SERVICE_URL: http://embed:8000 # pgvector + chunks (hybrid_search) DATABASE_URL: postgres://postgres:${POSTGRES_PASSWORD}@db:5432/postgres # W1.2 — Glitchtip error monitoring (DSN issued by manage.py bootstrap) SENTRY_DSN: ${GLITCHTIP_WEB_DSN} NEXT_PUBLIC_SENTRY_DSN: ${GLITCHTIP_WEB_DSN} volumes: - ${DATA_WIKI}:/data/ufo/wiki:ro - ${DATA_PROCESSING}:/data/ufo/processing:ro - ${DATA_RAW}:/data/ufo/raw:ro labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-web.rule=Host(`app.${DOMAIN_MAIN}`) || Host(`${DOMAIN_MAIN}`) || Host(`www.${DOMAIN_MAIN}`) - traefik.http.routers.disclosure-web.entrypoints=websecure - traefik.http.routers.disclosure-web.tls=true - traefik.http.routers.disclosure-web.tls.certresolver=letsencrypt - traefik.http.services.disclosure-web.loadbalancer.server.port=3000 # www → apex redirect - traefik.http.middlewares.disclosure-www-redir.redirectregex.regex=^https?://www\.${DOMAIN_MAIN}/(.*) - traefik.http.middlewares.disclosure-www-redir.redirectregex.replacement=https://${DOMAIN_MAIN}/$${1} - traefik.http.middlewares.disclosure-www-redir.redirectregex.permanent=true # ─── BGE-M3 embedding + reranker service (CPU only) ─────────────────────── embed: container_name: disclosure-embed build: context: ../embed-service restart: unless-stopped networks: [internal] environment: DEVICE: cpu EMBED_MODEL: BAAI/bge-m3 RERANK_MODEL: BAAI/bge-reranker-v2-m3 HF_HUB_DOWNLOAD_TIMEOUT: 600 volumes: - hf-cache:/cache healthcheck: test: ["CMD", "curl", "-fsS", "http://127.0.0.1:8000/health"] interval: 30s timeout: 10s retries: 5 start_period: 180s deploy: resources: limits: memory: 3g # ─── Glitchtip — self-hosted Sentry-compatible error monitor (W1.2) ─────── glitchtip-redis: container_name: disclosure-glitchtip-redis image: redis:7-alpine restart: unless-stopped networks: [internal] volumes: - glitchtip-redis-data:/data command: redis-server --appendonly yes glitchtip-web: container_name: disclosure-glitchtip-web image: glitchtip/glitchtip:v4.2 restart: unless-stopped networks: [internal, traefik] depends_on: db: { condition: service_healthy } glitchtip-redis: { condition: service_started } environment: DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@db:5432/glitchtip SECRET_KEY: ${GLITCHTIP_SECRET_KEY} REDIS_URL: redis://glitchtip-redis:6379/0 PORT: "8080" GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN} DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL} EMAIL_URL: consolemail:// ENABLE_USER_REGISTRATION: "false" # bootstrap admin via manage.py ENABLE_ORGANIZATION_CREATION: "false" CELERY_WORKER_AUTOSCALE: "1,3" CELERY_WORKER_MAX_TASKS_PER_CHILD: "10000" volumes: - glitchtip-uploads:/code/uploads labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-glitchtip.rule=Host(`glitchtip.disclosure.top`) - traefik.http.routers.disclosure-glitchtip.entrypoints=websecure - traefik.http.routers.disclosure-glitchtip.tls=true - traefik.http.routers.disclosure-glitchtip.tls.certresolver=letsencrypt - traefik.http.services.disclosure-glitchtip.loadbalancer.server.port=8080 glitchtip-worker: container_name: disclosure-glitchtip-worker image: glitchtip/glitchtip:v4.2 restart: unless-stopped networks: [internal] depends_on: db: { condition: service_healthy } glitchtip-redis: { condition: service_started } environment: DATABASE_URL: postgres://glitchtip:${GLITCHTIP_DB_PASSWORD}@db:5432/glitchtip SECRET_KEY: ${GLITCHTIP_SECRET_KEY} REDIS_URL: redis://glitchtip-redis:6379/0 GLITCHTIP_DOMAIN: ${GLITCHTIP_DOMAIN} DEFAULT_FROM_EMAIL: ${GLITCHTIP_DEFAULT_FROM_EMAIL} EMAIL_URL: consolemail:// CELERY_WORKER_AUTOSCALE: "1,3" CELERY_WORKER_MAX_TASKS_PER_CHILD: "10000" volumes: - glitchtip-uploads:/code/uploads command: ./bin/run-celery-with-beat.sh # ─── Forgejo — self-hosted Git + Actions CI (W1.2) ──────────────────────── forgejo: container_name: disclosure-forgejo image: codeberg.org/forgejo/forgejo:9 restart: unless-stopped networks: [internal, traefik] depends_on: db: { condition: service_healthy } environment: USER_UID: "1000" USER_GID: "1000" FORGEJO__database__DB_TYPE: postgres FORGEJO__database__HOST: db:5432 FORGEJO__database__NAME: forgejo FORGEJO__database__USER: forgejo FORGEJO__database__PASSWD: ${FORGEJO_DB_PASSWORD} FORGEJO__server__DOMAIN: ${FORGEJO_DOMAIN} FORGEJO__server__ROOT_URL: https://${FORGEJO_DOMAIN} FORGEJO__server__SSH_DOMAIN: ${FORGEJO_DOMAIN} FORGEJO__service__DISABLE_REGISTRATION: "true" # admin invites only FORGEJO__actions__ENABLED: "true" FORGEJO__security__INSTALL_LOCK: "true" volumes: - forgejo-data:/data labels: - traefik.enable=true - traefik.docker.network=traefik-public - traefik.http.routers.disclosure-forgejo.rule=Host(`forgejo.disclosure.top`) - traefik.http.routers.disclosure-forgejo.entrypoints=websecure - traefik.http.routers.disclosure-forgejo.tls=true - traefik.http.routers.disclosure-forgejo.tls.certresolver=letsencrypt - traefik.http.services.disclosure-forgejo.loadbalancer.server.port=3000 forgejo-runner: container_name: disclosure-forgejo-runner image: code.forgejo.org/forgejo/runner:6 restart: unless-stopped networks: [internal] # GID of the docker group on the host — lets the runner (uid 1000) talk # to the docker socket without running as root. group_add: - "988" depends_on: forgejo: { condition: service_started } environment: FORGEJO_INSTANCE_URL: http://forgejo:3000 FORGEJO_RUNNER_REGISTRATION_TOKEN: ${FORGEJO_RUNNER_TOKEN} FORGEJO_RUNNER_NAME: disclosure-runner volumes: - forgejo-runner-config:/data - /var/run/docker.sock:/var/run/docker.sock command: - sh - -c - | sleep 10 if [ ! -f /data/.runner ]; then forgejo-runner register --no-interactive --instance "$$FORGEJO_INSTANCE_URL" --token "$$FORGEJO_RUNNER_REGISTRATION_TOKEN" --name "$$FORGEJO_RUNNER_NAME" --labels 'ubuntu-latest:docker://node:20-bookworm,docker:host' fi forgejo-runner daemon