name: tokems x-build-args: &build-args BUILD_SHA: ${BUILD_SHA:-unknown} BUILD_TIME: ${BUILD_TIME:-unknown} BUILD_MIGRATION: ${BUILD_MIGRATION:-unknown} BUILD_MIGRATION_HASH: ${BUILD_MIGRATION_HASH:-unknown} x-build: &app-build context: . dockerfile: Dockerfile args: *build-args x-logging: &default-logging driver: json-file options: max-size: 10m max-file: '3' x-node-runtime: &node-runtime init: true restart: unless-stopped read_only: true tmpfs: - /tmp:size=64m,mode=1777 security_opt: - no-new-privileges:true cap_drop: - ALL logging: *default-logging x-app-environment: &app-environment NODE_ENV: production DEPLOYMENT_MODE: ${DEPLOYMENT_MODE:-production} DATABASE_URL: ${DOCKER_DATABASE_URL:-postgresql://conference:conference@postgres:5432/conference} REDIS_URL: ${DOCKER_REDIS_URL:-redis://redis:6379} S3_ENDPOINT: ${DOCKER_S3_ENDPOINT:-http://minio:9000} S3_PUBLIC_ENDPOINT: ${S3_PUBLIC_ENDPOINT:-http://localhost:19000} S3_ACCESS_KEY: ${S3_ACCESS_KEY:-${MINIO_ROOT_USER:-conference}} S3_SECRET_KEY: ${S3_SECRET_KEY:-${MINIO_ROOT_PASSWORD:-conference-secret}} S3_BUCKET: ${S3_BUCKET:-conference-assets} S3_REGION: ${S3_REGION:-us-east-1} DATABASE_POOL_SIZE: ${DATABASE_POOL_SIZE:-10} JWT_SECRET: ${JWT_SECRET:?JWT_SECRET must contain at least 32 random characters} TOKEMS_AGENT_ACCESS_ENABLED: ${TOKEMS_AGENT_ACCESS_ENABLED:-false} TOKEMS_AGENT_WRITES_ENABLED: ${TOKEMS_AGENT_WRITES_ENABLED:-false} TOKEMS_AGENT_CRITICAL_ACTIONS_ENABLED: ${TOKEMS_AGENT_CRITICAL_ACTIONS_ENABLED:-false} AGENT_ACCESS_TOKEN_SECRET: ${AGENT_ACCESS_TOKEN_SECRET:-} AGENT_ACCESS_TOKEN_PREVIOUS_SECRET: ${AGENT_ACCESS_TOKEN_PREVIOUS_SECRET:-} INVOICE_DOWNLOAD_SIGNING_SECRET: ${INVOICE_DOWNLOAD_SIGNING_SECRET:-} CUSTOMER_OTP_PEPPER: ${CUSTOMER_OTP_PEPPER:?CUSTOMER_OTP_PEPPER must contain at least 32 random characters} CUSTOMER_SESSION_SECRET: ${CUSTOMER_SESSION_SECRET:?CUSTOMER_SESSION_SECRET must contain at least 32 random characters} NOTIFICATION_PAYLOAD_ENCRYPTION_SECRET: ${NOTIFICATION_PAYLOAD_ENCRYPTION_SECRET:?NOTIFICATION_PAYLOAD_ENCRYPTION_SECRET must contain at least 32 random characters} CUSTOMER_OTP_MODE: ${CUSTOMER_OTP_MODE:-provider} CUSTOMER_OTP_ORG_DAILY_LIMIT: ${CUSTOMER_OTP_ORG_DAILY_LIMIT:-10000} CUSTOMER_OTP_PLATFORM_HOURLY_LIMIT: ${CUSTOMER_OTP_PLATFORM_HOURLY_LIMIT:-8} ALLOW_INSECURE_LOCAL_AUTH: ${ALLOW_INSECURE_LOCAL_AUTH:-false} ADMIN_USERNAME: ${ADMIN_USERNAME:-admin} ADMIN_USER_ID: ${ADMIN_USER_ID:-aaaaaaaa-aaaa-4aaa-8aaa-aaaaaaaaaaaa} ADMIN_EMAIL: ${ADMIN_EMAIL:-admin@tokems.local} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} SEED_DEMO_DATA: ${SEED_DEMO_DATA:-false} PUBLIC_ORGANIZATION_SLUG: ${PUBLIC_ORGANIZATION_SLUG:-geo-conference} PUBLIC_ORIGIN: ${PUBLIC_ORIGIN:?PUBLIC_ORIGIN must be the external scheme and host} ADMIN_ORIGIN: ${ADMIN_ORIGIN:?ADMIN_ORIGIN must be the external admin scheme and host} PAYMENT_PUBLIC_ORIGIN: ${PAYMENT_PUBLIC_ORIGIN:-} PAYMENT_PUBLIC_BASE_PATH: ${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui} PAYMENT_PUBLIC_URL: ${PAYMENT_PUBLIC_URL:-} PUBLIC_WEB_URL: ${PUBLIC_ORIGIN} ADMIN_WEB_URL: ${ADMIN_ORIGIN} PUBLIC_SITE_URL: ${PUBLIC_ORIGIN} PUBLIC_API_URL: ${PUBLIC_ORIGIN} PARTNER_ATTRIBUTION_SECRET: ${PARTNER_ATTRIBUTION_SECRET:-} PARTNER_PAYOUT_DATA_SECRET: ${PARTNER_PAYOUT_DATA_SECRET:-} PAYOUT_PUBLIC_URL: ${PAYOUT_PUBLIC_URL:-${PUBLIC_ORIGIN}} TRUST_PROXY: ${TRUST_PROXY:-127.0.0.1,::1,172.16.0.0/12} API_RATE_LIMIT_PER_MINUTE: ${API_RATE_LIMIT_PER_MINUTE:-5000} PAYMENT_WEBHOOK_SECRET: ${PAYMENT_WEBHOOK_SECRET:-} INTEGRATION_ENCRYPTION_KEY: ${INTEGRATION_ENCRYPTION_KEY:-} INTEGRATION_ENCRYPTION_KEY_VERSION: ${INTEGRATION_ENCRYPTION_KEY_VERSION:-1} INTEGRATION_ENCRYPTION_PREVIOUS_KEYS: ${INTEGRATION_ENCRYPTION_PREVIOUS_KEYS:-} ENABLE_LOCAL_PAYMENT_SIMULATION: ${ENABLE_LOCAL_PAYMENT_SIMULATION:-false} LOCAL_PAYMENT_SIMULATION_MOBILES: ${LOCAL_PAYMENT_SIMULATION_MOBILES:-} AI_API_URL: ${AI_API_URL:-} AI_API_KEY: ${AI_API_KEY:-} AI_MODEL: ${AI_MODEL:-conference-copywriter-local-v1} HTML_TEMPLATE_IMPORT_ENABLED: ${HTML_TEMPLATE_IMPORT_ENABLED:-true} HTML_TEMPLATE_IMPORT_ORG_ALLOWLIST: ${HTML_TEMPLATE_IMPORT_ORG_ALLOWLIST:-} HTML_TEMPLATE_ORG_ASSET_BYTES: ${HTML_TEMPLATE_ORG_ASSET_BYTES:-2147483648} HTML_TEMPLATE_ORG_ASSET_COUNT: ${HTML_TEMPLATE_ORG_ASSET_COUNT:-10000} HTML_TEMPLATE_AI_MAPPING_ENABLED: ${HTML_TEMPLATE_AI_MAPPING_ENABLED:-false} HTML_TEMPLATE_AI_ORG_MINUTE_LIMIT: ${HTML_TEMPLATE_AI_ORG_MINUTE_LIMIT:-5} HTML_TEMPLATE_AI_ORG_DAILY_LIMIT: ${HTML_TEMPLATE_AI_ORG_DAILY_LIMIT:-100} NOTIFICATION_WEBHOOK_URL: ${NOTIFICATION_WEBHOOK_URL:-http://notification-sink:4080/notifications} NOTIFICATION_WEBHOOK_TOKEN: ${NOTIFICATION_WEBHOOK_TOKEN:?NOTIFICATION_WEBHOOK_TOKEN is required} OUTBOX_POLL_INTERVAL_MS: ${OUTBOX_POLL_INTERVAL_MS:-2000} WORKER_CONCURRENCY: ${WORKER_CONCURRENCY:-5} INVENTORY_RELEASE_INTERVAL_MS: ${INVENTORY_RELEASE_INTERVAL_MS:-30000} SMS_RECEIPT_INTERVAL_MS: ${SMS_RECEIPT_INTERVAL_MS:-30000} INVOICE_ASYNC_EXPORT_THRESHOLD: ${INVOICE_ASYNC_EXPORT_THRESHOLD:-50000} BUILD_SHA: ${BUILD_SHA:-unknown} BUILD_TIME: ${BUILD_TIME:-unknown} BUILD_MIGRATION: ${BUILD_MIGRATION:-unknown} BUILD_MIGRATION_HASH: ${BUILD_MIGRATION_HASH:-unknown} services: postgres: image: ${POSTGRES_IMAGE:-pgvector/pgvector:pg16} restart: unless-stopped environment: POSTGRES_DB: conference POSTGRES_USER: conference POSTGRES_PASSWORD: conference ports: - '127.0.0.1:${POSTGRES_PORT:-15432}:5432' volumes: - tokems-postgres:/var/lib/postgresql/data healthcheck: test: [CMD-SHELL, 'pg_isready -U conference -d conference'] interval: 5s timeout: 3s retries: 20 start_period: 10s logging: *default-logging redis: image: redis:7.4-alpine command: [redis-server, --appendonly, 'yes'] restart: unless-stopped ports: - '127.0.0.1:${REDIS_PORT:-16379}:6379' volumes: - tokems-redis:/data healthcheck: test: [CMD, redis-cli, ping] interval: 5s timeout: 3s retries: 20 start_period: 5s logging: *default-logging minio: image: quay.io/minio/minio:RELEASE.2025-09-07T16-13-09Z@sha256:14cea493d9a34af32f524e538b8346cf79f3321eff8e708c1e2960462bd8936e command: [server, /data, --console-address, ':9001'] restart: unless-stopped environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-conference} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-conference-secret} ports: - '127.0.0.1:${MINIO_API_PORT:-19000}:9000' - '127.0.0.1:${MINIO_CONSOLE_PORT:-19001}:9001' volumes: - tokems-minio:/data healthcheck: test: [CMD, curl, --fail, http://127.0.0.1:9000/minio/health/live] interval: 5s timeout: 3s retries: 30 start_period: 10s logging: *default-logging minio-init: image: quay.io/minio/mc:RELEASE.2025-08-13T08-35-41Z@sha256:a7fe349ef4bd8521fb8497f55c6042871b2ae640607cf99d9bede5e9bdf11727 restart: 'no' environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-conference} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-conference-secret} S3_BUCKET: ${S3_BUCKET:-conference-assets} depends_on: minio: condition: service_healthy entrypoint: - /bin/sh - -ec - | mc alias set local http://minio:9000 "$$MINIO_ROOT_USER" "$$MINIO_ROOT_PASSWORD" mc mb --ignore-existing "local/$$S3_BUCKET" mc anonymous set none "local/$$S3_BUCKET" logging: *default-logging mailpit: image: axllent/mailpit@sha256:5a49a77c5bdbe7c5474450b4f46348d09949df3695257729c93a30369382d4f6 restart: unless-stopped ports: - '127.0.0.1:${MAILPIT_SMTP_PORT:-1025}:1025' - '127.0.0.1:${MAILPIT_WEB_PORT:-8025}:8025' healthcheck: test: [CMD, wget, --quiet, --spider, http://127.0.0.1:8025/livez] interval: 5s timeout: 3s retries: 20 start_period: 5s logging: *default-logging notification-sink: <<: *node-runtime image: tokems-notification-sink:local build: <<: *app-build target: notification-sink environment: # Container listen port stays fixed; only the host mapping below is env-driven. NOTIFICATION_SINK_PORT: 4080 NOTIFICATION_SINK_TOKEN: ${NOTIFICATION_WEBHOOK_TOKEN:?NOTIFICATION_WEBHOOK_TOKEN is required} ports: - '127.0.0.1:${NOTIFICATION_SINK_HOST_PORT:-${NOTIFICATION_SINK_PORT:-4080}}:4080' healthcheck: test: - CMD - node - -e - >- fetch('http://127.0.0.1:4080/health').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) interval: 5s timeout: 3s retries: 20 start_period: 5s db-init: image: tokems-api:local build: <<: *app-build target: api args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 environment: *app-environment command: - /bin/sh - -ec - >- node node_modules/@conference/database/dist/migrate.js && if [ "$$SEED_DEMO_DATA" = "true" ]; then node node_modules/@conference/database/dist/seed.js; fi depends_on: postgres: condition: service_healthy restart: 'no' read_only: true tmpfs: - /tmp:size=64m,mode=1777 security_opt: - no-new-privileges:true cap_drop: - ALL logging: *default-logging api: <<: *node-runtime image: tokems-api:local build: <<: *app-build target: api args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 environment: <<: *app-environment BATCH_PURCHASE_CREATION_ENABLED: ${BATCH_PURCHASE_CREATION_ENABLED:-true} API_PORT: 4100 API_BIND_ADDRESS: 0.0.0.0 API_INTERNAL_CONTAINER: 'true' expose: - '4100' depends_on: db-init: condition: service_completed_successfully redis: condition: service_healthy minio-init: condition: service_completed_successfully healthcheck: test: - CMD - node - -e - >- fetch('http://127.0.0.1:4100/api/v1/health').then(async (response) => { const body = await response.json(); if (!response.ok || body.status !== 'ok' || body.database?.ok !== true) process.exit(1) }).catch(() => process.exit(1)) interval: 5s timeout: 4s retries: 30 start_period: 10s worker: <<: *node-runtime tmpfs: - /tmp:size=${WORKER_TMPFS_SIZE:-512m},mode=1777 image: tokems-worker:local build: <<: *app-build target: worker args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 environment: *app-environment depends_on: db-init: condition: service_completed_successfully redis: condition: service_healthy notification-sink: condition: service_healthy healthcheck: test: [CMD, node, -e, 'process.kill(1, 0)'] interval: 10s timeout: 3s retries: 3 start_period: 10s web: <<: *node-runtime image: tokems-web:local build: <<: *app-build target: web args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 environment: # Conference site must stay at domain root; never inherit payment base path. NUXT_APP_BASE_URL: / NUXT_API_INTERNAL_BASE: http://api:4100/api/v1 NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} NUXT_PUBLIC_PAYMENT_ORIGIN: ${PAYMENT_PUBLIC_ORIGIN:-} NUXT_PUBLIC_PAYMENT_BASE_PATH: ${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui} NUXT_PUBLIC_CONFERENCE_ORIGIN: ${PUBLIC_ORIGIN} NUXT_PUBLIC_PAYMENT_SURFACE: 'false' BUILD_SHA: ${BUILD_SHA:-unknown} BUILD_TIME: ${BUILD_TIME:-unknown} BUILD_MIGRATION: ${BUILD_MIGRATION:-unknown} BUILD_MIGRATION_HASH: ${BUILD_MIGRATION_HASH:-unknown} HOST: 0.0.0.0 PORT: 3000 expose: - '3000' depends_on: api: condition: service_healthy healthcheck: test: - CMD - node - -e - >- fetch('http://127.0.0.1:3000/version.json').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) interval: 5s timeout: 4s retries: 30 start_period: 10s payment-web: <<: *node-runtime image: tokems-web:local build: <<: *app-build target: web args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 environment: NUXT_APP_BASE_URL: ${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui}/ NUXT_API_INTERNAL_BASE: http://api:4100/api/v1 NUXT_PUBLIC_API_BASE: ${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui}/api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} NUXT_PUBLIC_PAYMENT_ORIGIN: ${PAYMENT_PUBLIC_ORIGIN:-} NUXT_PUBLIC_PAYMENT_BASE_PATH: ${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui} NUXT_PUBLIC_CONFERENCE_ORIGIN: ${PUBLIC_ORIGIN} NUXT_PUBLIC_PAYMENT_SURFACE: 'true' BUILD_SHA: ${BUILD_SHA:-unknown} BUILD_TIME: ${BUILD_TIME:-unknown} BUILD_MIGRATION: ${BUILD_MIGRATION:-unknown} BUILD_MIGRATION_HASH: ${BUILD_MIGRATION_HASH:-unknown} HOST: 0.0.0.0 PORT: 3000 expose: - '3000' depends_on: api: condition: service_healthy healthcheck: test: - CMD - node - -e - >- fetch('http://127.0.0.1:3000${PAYMENT_PUBLIC_BASE_PATH:-/pay/hui}/version.json').then((response) => { if (!response.ok) process.exit(1) }).catch(() => process.exit(1)) interval: 5s timeout: 4s retries: 30 start_period: 10s admin: image: tokems-admin:local build: <<: *app-build target: admin args: <<: *build-args NUXT_PUBLIC_API_BASE: /api/v1 NUXT_PUBLIC_ORGANIZATION_SLUG: ${NUXT_PUBLIC_ORGANIZATION_SLUG:-geo-conference} VITE_API_BASE: /api/v1 VITE_ADMIN_BASE: /admin/ VITE_SIMPLE_AUTH: ${VITE_SIMPLE_AUTH:-false} restart: unless-stopped read_only: true tmpfs: - /var/cache/nginx:size=32m - /var/run:size=4m - /tmp:size=16m,mode=1777 security_opt: - no-new-privileges:true expose: - '8080' depends_on: api: condition: service_healthy healthcheck: test: [CMD, wget, --quiet, --spider, http://127.0.0.1:8080/healthz] interval: 5s timeout: 3s retries: 20 start_period: 5s logging: *default-logging gateway: image: tokems-gateway:local build: <<: *app-build target: gateway restart: unless-stopped read_only: true tmpfs: - /var/cache/nginx:size=32m - /var/run:size=4m - /tmp:size=16m,mode=1777 security_opt: - no-new-privileges:true ports: - '${GATEWAY_BIND_ADDRESS:-127.0.0.1}:${GATEWAY_PORT:-8088}:8080' depends_on: api: condition: service_healthy web: condition: service_healthy payment-web: condition: service_healthy admin: condition: service_healthy healthcheck: test: [CMD, wget, --quiet, --spider, http://127.0.0.1:8080/healthz] interval: 5s timeout: 3s retries: 20 start_period: 5s logging: *default-logging volumes: tokems-postgres: name: tokems-postgres tokems-redis: name: tokems-redis tokems-minio: name: tokems-minio