# Keep the Compose project name stable across checkout-directory and deployment-platform names. Alloy # uses the same value below to restrict Docker log discovery to this Kanera deployment. name: ${COMPOSE_PROJECT_NAME:-kanera} # Third-party images are pinned to exact patch versions on purpose. Dokploy redeploys run # `docker compose up -d`, which recreates any service whose image digest moved, so a floating tag # would let Postgres, Valkey, or the monitoring stack jump versions during a deploy that was only # meant to ship application code. Patch bumps arrive as reviewable Dependabot PRs instead # (see .github/dependabot.yml) so an upgrade is always a commit that can be reverted. # Environment shared verbatim by the three services built from the Kanera API image (app api, # worker, public api). Compose ignores top-level `x-` keys, so this exists only to be merged. # Each service merges it and then adds or overrides what differs — ports, pool sizes, and the # per-surface rate limits. Deliberately not merged into `migrate` or `admin-api`: they carry a # smaller subset, and merging would silently hand them variables they are not meant to see. x-app-env: &app-env NODE_ENV: production # Lifecycle and activation events also originate in the worker and public API. ANALYTICS_ENABLED: ${ANALYTICS_ENABLED:-false} ANALYTICS_PROVIDER: ${ANALYTICS_PROVIDER:-} POSTHOG_PROJECT_KEY: ${POSTHOG_PROJECT_KEY:-} POSTHOG_API_HOST: ${POSTHOG_API_HOST:-} DATABASE_URL: postgres://kanera:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/kanera REDIS_URL: redis://valkey:6379/0 DATABASE_SSL: ${DATABASE_SSL:-false} NODE_OPTIONS: ${NODE_OPTIONS:-} UV_THREADPOOL_SIZE: ${UV_THREADPOOL_SIZE:-16} PG_IDLE_TIMEOUT_MS: ${PG_IDLE_TIMEOUT_MS:-30000} PG_CONNECTION_TIMEOUT_MS: ${PG_CONNECTION_TIMEOUT_MS:-5000} PG_STATEMENT_TIMEOUT_MS: ${PG_STATEMENT_TIMEOUT_MS:-30000} SLOW_QUERY_LOG_MS: ${SLOW_QUERY_LOG_MS:-250} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} MCP_INTERNAL_SECRET: ${MCP_INTERNAL_SECRET:?set MCP_INTERNAL_SECRET in .env} MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?set MFA_ENCRYPTION_KEY in .env} MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?set MEDIA_SIGNING_SECRET in .env} SECRETS_ENCRYPTION_KEY: ${SECRETS_ENCRYPTION_KEY:-} JWT_ACCESS_TTL: ${JWT_ACCESS_TTL:-5m} JWT_REFRESH_TTL_DAYS: ${JWT_REFRESH_TTL_DAYS:-10} API_PUBLIC_URL: ${API_PUBLIC_URL:-${WEB_ORIGIN}} WEB_ORIGIN: ${WEB_ORIGIN:?set WEB_ORIGIN in .env} COOKIE_DOMAIN: ${COOKIE_DOMAIN:?set COOKIE_DOMAIN in .env} COOKIE_SECURE: ${COOKIE_SECURE:?set COOKIE_SECURE in .env} KANERA_ENVIRONMENT: ${KANERA_ENVIRONMENT:-production} KANERA_DEPLOYMENT_MODE: ${KANERA_DEPLOYMENT_MODE:-self_hosted} KANERA_HOSTED_MODE_TOKEN: ${KANERA_HOSTED_MODE_TOKEN:-} KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS: ${KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS:-false} SIGNUPS_ENABLED: ${SIGNUPS_ENABLED:-true} EMAIL_VERIFICATION_ENABLED: ${EMAIL_VERIFICATION_ENABLED:-false} CLOUDFLARE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY:-} CLOUDFLARE_TURNSTILE_SECRET_KEY: ${CLOUDFLARE_TURNSTILE_SECRET_KEY:-} ATTACHMENT_MAX_BYTES: ${ATTACHMENT_MAX_BYTES:-104857600} HOSTED_FREE_ATTACHMENT_MAX_BYTES: ${HOSTED_FREE_ATTACHMENT_MAX_BYTES:-5242880} HOSTED_FREE_STORAGE_QUOTA_BYTES: ${HOSTED_FREE_STORAGE_QUOTA_BYTES:-262144000} HOSTED_PAID_STORAGE_QUOTA_BYTES: ${HOSTED_PAID_STORAGE_QUOTA_BYTES:-214748364800} HOSTED_PRO_PRICE_MONTHLY_CENTS: ${HOSTED_PRO_PRICE_MONTHLY_CENTS:-500} HOSTED_PRO_PRICE_ANNUAL_CENTS: ${HOSTED_PRO_PRICE_ANNUAL_CENTS:-4900} HOSTED_FREE_MAX_BOARDS: ${HOSTED_FREE_MAX_BOARDS:-3} HOSTED_FREE_MAX_ORG_MEMBERS: ${HOSTED_FREE_MAX_ORG_MEMBERS:-4} HOSTED_FREE_MAX_ENABLED_AUTOMATIONS: ${HOSTED_FREE_MAX_ENABLED_AUTOMATIONS:-3} HOSTED_FREE_MAX_AUTOMATION_EXECUTIONS_MONTHLY: ${HOSTED_FREE_MAX_AUTOMATION_EXECUTIONS_MONTHLY:-100} HOSTED_FREE_API_RATE_LIMIT_PER_SECOND: ${HOSTED_FREE_API_RATE_LIMIT_PER_SECOND:-5} HOSTED_FREE_API_RATE_LIMIT_PER_MINUTE: ${HOSTED_FREE_API_RATE_LIMIT_PER_MINUTE:-60} HOSTED_FREE_MAX_GUEST_BOARDS: ${HOSTED_FREE_MAX_GUEST_BOARDS:-1} HOSTED_TRIAL_DAYS: ${HOSTED_TRIAL_DAYS:-30} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} STRIPE_PRICE_ID_PRO_ANNUAL: ${STRIPE_PRICE_ID_PRO_ANNUAL:-} UPLOADS_DIR: /data/uploads S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-} S3_BUCKET: ${S3_BUCKET:-} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} S3_PUBLIC_URL_PREFIX: ${S3_PUBLIC_URL_PREFIX:-} SLOW_REQUEST_LOG_MS: ${SLOW_REQUEST_LOG_MS:-2500} USER_DISPLAY_CACHE_TTL_MS: ${USER_DISPLAY_CACHE_TTL_MS:-60000} METRICS_ENABLED: ${METRICS_ENABLED:-true} METRICS_TOKEN: ${METRICS_TOKEN:-} REALTIME_EMIT_METRICS_ENABLED: ${REALTIME_EMIT_METRICS_ENABLED:-false} REALTIME_EMIT_METRICS_SAMPLE_RATE: ${REALTIME_EMIT_METRICS_SAMPLE_RATE:-0.01} REALTIME_EMIT_METRICS_MIN_BYTES: ${REALTIME_EMIT_METRICS_MIN_BYTES:-0} OPS_ALERTS_ENABLED: ${OPS_ALERTS_ENABLED:-true} OPS_ALERT_THROTTLE_MS: ${OPS_ALERT_THROTTLE_MS:-300000} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} GITHUB_APP_ID: ${GITHUB_APP_ID:-} GITHUB_APP_SLUG: ${GITHUB_APP_SLUG:-} GITHUB_APP_PRIVATE_KEY: ${GITHUB_APP_PRIVATE_KEY:-} GITHUB_APP_CLIENT_ID: ${GITHUB_APP_CLIENT_ID:-} GITHUB_APP_CLIENT_SECRET: ${GITHUB_APP_CLIENT_SECRET:-} GITHUB_APP_WEBHOOK_SECRET: ${GITHUB_APP_WEBHOOK_SECRET:-} TRELLO_API_KEY: ${TRELLO_API_KEY:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURITY: ${SMTP_SECURITY:-starttls} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Kanera} SMTP_IDENTITY_DOMAIN: ${SMTP_IDENTITY_DOMAIN:-} INTERNAL_NOTIFICATION_EMAILS: ${INTERNAL_NOTIFICATION_EMAILS:-} LIFECYCLE_FEEDBACK_EMAIL: ${LIFECYCLE_FEEDBACK_EMAIL:-} SUPPORT_SESSION_TTL_MINUTES: ${SUPPORT_SESSION_TTL_MINUTES:-60} services: postgres: image: postgres:18.6-alpine restart: unless-stopped # pg_stat_statements must be loaded at server start via shared_preload_libraries; CREATE EXTENSION # alone is not enough. track=top records top-level statements (not every nested statement) to keep # overhead low. The extension itself is created in docker/postgres/init.sql. command: - "postgres" - "-c" - "shared_preload_libraries=pg_stat_statements" - "-c" - "pg_stat_statements.track=top" # Large board-summary sorts otherwise spill to disk at Postgres's stock 4 MB limit. - "-c" - "work_mem=${POSTGRES_WORK_MEM:-16MB}" environment: POSTGRES_USER: kanera POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} POSTGRES_DB: kanera PGDATA: /var/lib/postgresql/18/docker volumes: - kanera_pgdata:/var/lib/postgresql - ./docker/postgres/init.sql:/docker-entrypoint-initdb.d/00-init.sql:ro,Z ports: # Keep the bundled database unreachable from external interfaces. Use an SSH tunnel for remote access. - "127.0.0.1:${POSTGRES_BIND_PORT:-5433}:5432" healthcheck: test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -p 5432 -U kanera -d kanera"] interval: 5s timeout: 3s retries: 10 valkey: image: valkey/valkey:9.1.3-alpine restart: unless-stopped healthcheck: test: ["CMD", "valkey-cli", "ping"] interval: 5s timeout: 3s retries: 10 # One-shot schema migration. drizzle's migrator takes no advisory lock, so letting every scaled # `api` replica run `start:migrate` would race the same DDL on each deploy (crash-loops until one # wins). Instead this runs to completion once; api/worker/public-api gate on it via # service_completed_successfully and never serve traffic before the schema is ready. On a deploy # with no pending migrations it is a fast no-op. Carries only the env drizzle/env validation needs. migrate: restart: "no" build: context: . dockerfile: apps/api/Dockerfile pull: true command: ["node", "--import", "tsx", "src/scripts/migrate.ts"] environment: NODE_ENV: production DATABASE_URL: postgres://kanera:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/kanera REDIS_URL: redis://valkey:6379/0 DATABASE_SSL: ${DATABASE_SSL:-false} PG_IDLE_TIMEOUT_MS: ${PG_IDLE_TIMEOUT_MS:-30000} PG_CONNECTION_TIMEOUT_MS: ${PG_CONNECTION_TIMEOUT_MS:-5000} # Migrations include data reconciliation and must not inherit the 30s request-query limit. PG_STATEMENT_TIMEOUT_MS: ${MIGRATION_PG_STATEMENT_TIMEOUT_MS:-0} SLOW_QUERY_LOG_MS: ${SLOW_QUERY_LOG_MS:-250} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} MCP_INTERNAL_SECRET: ${MCP_INTERNAL_SECRET:?set MCP_INTERNAL_SECRET in .env} MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?set MFA_ENCRYPTION_KEY in .env} MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?set MEDIA_SIGNING_SECRET in .env} KANERA_DEPLOYMENT_MODE: ${KANERA_DEPLOYMENT_MODE:-self_hosted} KANERA_HOSTED_MODE_TOKEN: ${KANERA_HOSTED_MODE_TOKEN:-} KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS: ${KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS:-false} SIGNUPS_ENABLED: ${SIGNUPS_ENABLED:-true} EMAIL_VERIFICATION_ENABLED: ${EMAIL_VERIFICATION_ENABLED:-false} CLOUDFLARE_TURNSTILE_SITE_KEY: ${CLOUDFLARE_TURNSTILE_SITE_KEY:-} CLOUDFLARE_TURNSTILE_SECRET_KEY: ${CLOUDFLARE_TURNSTILE_SECRET_KEY:-} STRIPE_SECRET_KEY: ${STRIPE_SECRET_KEY:-} STRIPE_PUBLISHABLE_KEY: ${STRIPE_PUBLISHABLE_KEY:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_ID_PRO_MONTHLY: ${STRIPE_PRICE_ID_PRO_MONTHLY:-} STRIPE_PRICE_ID_PRO_ANNUAL: ${STRIPE_PRICE_ID_PRO_ANNUAL:-} depends_on: postgres: condition: service_healthy api: restart: unless-stopped init: true # Compose reads this on every deploy/redeploy, so Dokploy keeps the desired # API process count without a separate post-deploy `--scale api=N` command. scale: ${API_REPLICAS:-2} build: context: . dockerfile: apps/api/Dockerfile pull: true # Migrations run once in the dedicated `migrate` service (see depends_on); replicas just start. command: ["node", "--import", "tsx", "src/index.ts"] environment: <<: *app-env API_PORT: 3000 API_TRUST_PROXY: ${API_TRUST_PROXY:-false} # Capacity tuning. The bundled production Compose default is 20; set PG_POOL_MAX # in .env to override it. See "Performance tuning" in DEPLOY.md before raising it. PG_POOL_MAX: ${PG_POOL_MAX:-20} PUBLIC_API_OAUTH_ISSUER: ${PUBLIC_API_OAUTH_ISSUER:-http://localhost:3001} MCP_PUBLIC_URL: ${MCP_SERVER_PUBLIC_URL:-http://localhost:3002/mcp} AUTH_RATE_LIMIT_ENABLED: ${AUTH_RATE_LIMIT_ENABLED:-true} AUTH_RATE_LIMIT_MAX: ${AUTH_RATE_LIMIT_MAX:-10} AUTH_RATE_LIMIT_WINDOW_MS: ${AUTH_RATE_LIMIT_WINDOW_MS:-60000} # Opt-in only: typical realtime messages are small enough to favor CPU/latency over compression. REALTIME_WEBSOCKET_COMPRESSION_ENABLED: ${REALTIME_WEBSOCKET_COMPRESSION_ENABLED:-false} REALTIME_WEBSOCKET_COMPRESSION_THRESHOLD_BYTES: ${REALTIME_WEBSOCKET_COMPRESSION_THRESHOLD_BYTES:-1024} volumes: - kanera_uploads:/data/uploads depends_on: migrate: condition: service_completed_successfully postgres: condition: service_healthy valkey: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3000/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 20s expose: - "3000" # The realtime server holds one file descriptor per connected WebSocket. The default # container limit (often 1024) caps concurrent clients far below this app's capacity; # raise it so the socket count, not the FD limit, is the ceiling. See DEPLOY.md. ulimits: nofile: soft: 1048576 hard: 1048576 # Run exactly ONE worker. It owns all schedulers (digests, overdue, trial-expiry, cleanups, # email/push queues) plus the realtime outbox dispatcher and presence reaper. The outbox dispatcher # is concurrency-safe (FOR UPDATE SKIP LOCKED), but the schedulers are not — a second replica would # double-send emails and notifications. Do not `--scale worker`. See DEPLOY.md "Scaling". worker: restart: unless-stopped init: true # Pinned to one instance (same `scale` convention as `api`). Do not raise this. scale: 1 build: context: . dockerfile: apps/api/Dockerfile pull: true command: ["node", "--import", "tsx", "src/worker-index.ts"] environment: <<: *app-env WORKER_PORT: 3003 PG_POOL_MAX: ${WORKER_PG_POOL_MAX:-5} WORKER_PG_POOL_MAX: ${WORKER_PG_POOL_MAX:-5} # Recipient for the Monday 07:00 UTC system recap (also seeds the first admin account). ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:-http://localhost:4300} REALTIME_OUTBOX_POLL_MS: ${REALTIME_OUTBOX_POLL_MS:-1000} REALTIME_OUTBOX_RETENTION_DAYS: ${REALTIME_OUTBOX_RETENTION_DAYS:-30} # Retention windows for the daily cleanup sweeps the worker owns (bounds otherwise-unbounded # tables). Defaults match apps/api/src/env.ts; override only to tune growth/compliance tradeoffs. OUTBOX_STUCK_RETENTION_DAYS: ${OUTBOX_STUCK_RETENTION_DAYS:-30} ACTIVITY_EVENT_RETENTION_DAYS: ${ACTIVITY_EVENT_RETENTION_DAYS:-730} ADMIN_AUDIT_LOG_RETENTION_DAYS: ${ADMIN_AUDIT_LOG_RETENTION_DAYS:-1095} NOTIFICATION_READ_RETENTION_DAYS: ${NOTIFICATION_READ_RETENTION_DAYS:-90} NOTIFICATION_MAX_RETENTION_DAYS: ${NOTIFICATION_MAX_RETENTION_DAYS:-365} AUTH_TOKEN_RETENTION_DAYS: ${AUTH_TOKEN_RETENTION_DAYS:-30} volumes: - kanera_uploads:/data/uploads depends_on: migrate: condition: service_completed_successfully postgres: condition: service_healthy valkey: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3003/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 20s expose: - "3003" public-api: restart: unless-stopped init: true scale: ${PUBLIC_API_REPLICAS:-1} build: context: . dockerfile: apps/api/Dockerfile pull: true command: ["node", "--import", "tsx", "src/public-api-index.ts"] environment: <<: *app-env API_PORT: 3000 PUBLIC_API_PORT: 3001 PUBLIC_API_OAUTH_ISSUER: ${PUBLIC_API_OAUTH_ISSUER:-http://localhost:3001} MCP_PUBLIC_URL: ${MCP_SERVER_PUBLIC_URL:-http://localhost:3002/mcp} PUBLIC_API_RATE_LIMIT_ENABLED: ${PUBLIC_API_RATE_LIMIT_ENABLED:-true} PUBLIC_API_IP_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_IP_RATE_LIMIT_PER_MINUTE:-60} PUBLIC_API_FAILED_KEY_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_FAILED_KEY_RATE_LIMIT_PER_MINUTE:-10} PUBLIC_API_KEY_RATE_LIMIT_PER_SECOND: ${PUBLIC_API_KEY_RATE_LIMIT_PER_SECOND:-20} PUBLIC_API_KEY_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_KEY_RATE_LIMIT_PER_MINUTE:-400} PUBLIC_API_UPLOAD_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_UPLOAD_RATE_LIMIT_PER_MINUTE:-30} PUBLIC_API_RATE_LIMIT_WINDOW_MS: ${PUBLIC_API_RATE_LIMIT_WINDOW_MS:-60000} PUBLIC_API_RATE_LIMIT_QUEUE_SIZE: ${PUBLIC_API_RATE_LIMIT_QUEUE_SIZE:-10} PUBLIC_API_RATE_LIMIT_QUEUE_MAX_WAIT_MS: ${PUBLIC_API_RATE_LIMIT_QUEUE_MAX_WAIT_MS:-10000} PUBLIC_API_TRUST_PROXY: ${PUBLIC_API_TRUST_PROXY:-false} # Each public-api process keeps its own DB pool; size it like the app api. See DEPLOY.md. PG_POOL_MAX: ${PG_POOL_MAX:-20} volumes: - kanera_uploads:/data/uploads depends_on: migrate: condition: service_completed_successfully api: condition: service_healthy postgres: condition: service_healthy valkey: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3001/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 20s expose: - "3001" # Disabled by default. Set COMPOSE_PROFILES=admin to include the management portal, then route its # port 3002 through your reverse proxy. Profiled-service values use empty fallbacks so ordinary # Compose runs do not demand admin-only secrets; admin-api validates them when the profile starts. admin-api: profiles: ["admin"] restart: unless-stopped init: true build: context: . dockerfile: apps/api/Dockerfile pull: true command: ["node", "--import", "tsx", "src/admin-index.ts"] environment: NODE_ENV: production ADMIN_API_PORT: 3002 DATABASE_URL: postgres://kanera:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/kanera REDIS_URL: redis://valkey:6379/0 DATABASE_SSL: ${DATABASE_SSL:-false} PG_POOL_MAX: ${PG_POOL_MAX:-10} PG_IDLE_TIMEOUT_MS: ${PG_IDLE_TIMEOUT_MS:-30000} PG_CONNECTION_TIMEOUT_MS: ${PG_CONNECTION_TIMEOUT_MS:-5000} PG_STATEMENT_TIMEOUT_MS: ${PG_STATEMENT_TIMEOUT_MS:-30000} SLOW_QUERY_LOG_MS: ${SLOW_QUERY_LOG_MS:-250} SLOW_REQUEST_LOG_MS: ${SLOW_REQUEST_LOG_MS:-2500} KANERA_DEPLOYMENT_MODE: ${KANERA_DEPLOYMENT_MODE:-self_hosted} KANERA_HOSTED_MODE_TOKEN: ${KANERA_HOSTED_MODE_TOKEN:-} KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS: ${KANERA_ALLOW_PRIVATE_NOTIFICATION_DESTINATIONS:-false} JWT_SECRET: ${JWT_SECRET:?set JWT_SECRET in .env} MCP_INTERNAL_SECRET: ${MCP_INTERNAL_SECRET:?set MCP_INTERNAL_SECRET in .env} MFA_ENCRYPTION_KEY: ${MFA_ENCRYPTION_KEY:?set MFA_ENCRYPTION_KEY in .env} MEDIA_SIGNING_SECRET: ${MEDIA_SIGNING_SECRET:?set MEDIA_SIGNING_SECRET in .env} SECRETS_ENCRYPTION_KEY: ${SECRETS_ENCRYPTION_KEY:-} ADMIN_JWT_SECRET: ${ADMIN_JWT_SECRET:-} ADMIN_JWT_ACCESS_TTL: ${ADMIN_JWT_ACCESS_TTL:-15m} ADMIN_JWT_REFRESH_TTL_DAYS: ${ADMIN_JWT_REFRESH_TTL_DAYS:-7} ADMIN_LOGIN_RATE_LIMIT_MAX: ${ADMIN_LOGIN_RATE_LIMIT_MAX:-5} ADMIN_LOGIN_RATE_LIMIT_WINDOW_MS: ${ADMIN_LOGIN_RATE_LIMIT_WINDOW_MS:-300000} ADMIN_WEB_ORIGIN: ${ADMIN_WEB_ORIGIN:-http://localhost:4300} ADMIN_TRUST_PROXY: ${ADMIN_TRUST_PROXY:-true} ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} ADMIN_COOKIE_DOMAIN: ${ADMIN_COOKIE_DOMAIN:-} COOKIE_DOMAIN: ${COOKIE_DOMAIN:?set COOKIE_DOMAIN in .env} COOKIE_SECURE: ${COOKIE_SECURE:?set COOKIE_SECURE in .env} API_PUBLIC_URL: ${API_PUBLIC_URL:-${WEB_ORIGIN}} WEB_ORIGIN: ${WEB_ORIGIN:?set WEB_ORIGIN in .env} # password-reset + support-session enter links SUPPORT_SESSION_TTL_MINUTES: ${SUPPORT_SESSION_TTL_MINUTES:-60} # lifetime of minted support tokens OPS_ALERTS_ENABLED: ${OPS_ALERTS_ENABLED:-true} OPS_ALERT_THROTTLE_MS: ${OPS_ALERT_THROTTLE_MS:-300000} ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_SECURITY: ${SMTP_SECURITY:-starttls} SMTP_USER: ${SMTP_USER:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_FROM_EMAIL: ${SMTP_FROM_EMAIL:-} SMTP_FROM_NAME: ${SMTP_FROM_NAME:-Kanera} SMTP_IDENTITY_DOMAIN: ${SMTP_IDENTITY_DOMAIN:-} UPLOADS_DIR: /data/uploads S3_ENDPOINT: ${S3_ENDPOINT:-} S3_REGION: ${S3_REGION:-} S3_BUCKET: ${S3_BUCKET:-} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY:-} S3_PUBLIC_URL_PREFIX: ${S3_PUBLIC_URL_PREFIX:-} volumes: # Demo resets must write and purge the same local tenant namespaces served by the main API. - kanera_uploads:/data/uploads depends_on: migrate: condition: service_completed_successfully postgres: condition: service_healthy valkey: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3002/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 20s expose: - "3002" mcp: restart: unless-stopped init: true scale: ${MCP_REPLICAS:-1} build: context: . dockerfile: apps/mcp/Dockerfile pull: true environment: NODE_ENV: production KANERA_PUBLIC_API_URL: http://public-api:3001 MCP_PORT: 3002 MCP_SERVER_PUBLIC_URL: ${MCP_SERVER_PUBLIC_URL:?set MCP_SERVER_PUBLIC_URL in .env} OPENAI_APPS_CHALLENGE_TOKEN: ${OPENAI_APPS_CHALLENGE_TOKEN:-} OAUTH_ISSUER_URL: ${PUBLIC_API_OAUTH_ISSUER:-http://localhost:3001} REDIS_URL: redis://valkey:6379 MCP_INTERNAL_SECRET: ${MCP_INTERNAL_SECRET:?set MCP_INTERNAL_SECRET in .env} MCP_BODY_MAX_BYTES: ${MCP_BODY_MAX_BYTES:-1048576} MCP_TOOL_OUTPUT_MAX_BYTES: ${MCP_TOOL_OUTPUT_MAX_BYTES:-1048576} MCP_UPSTREAM_TIMEOUT_MS: ${MCP_UPSTREAM_TIMEOUT_MS:-15000} PUBLIC_API_IP_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_IP_RATE_LIMIT_PER_MINUTE:-60} PUBLIC_API_KEY_RATE_LIMIT_PER_MINUTE: ${PUBLIC_API_KEY_RATE_LIMIT_PER_MINUTE:-400} PUBLIC_API_RATE_LIMIT_WINDOW_MS: ${PUBLIC_API_RATE_LIMIT_WINDOW_MS:-60000} MCP_TRUST_PROXY: ${MCP_TRUST_PROXY:-true} MCP_REQUEST_TIMEOUT_MS: ${MCP_REQUEST_TIMEOUT_MS:-30000} MCP_HEADERS_TIMEOUT_MS: ${MCP_HEADERS_TIMEOUT_MS:-10000} MCP_KEEP_ALIVE_TIMEOUT_MS: ${MCP_KEEP_ALIVE_TIMEOUT_MS:-5000} METRICS_ENABLED: ${METRICS_ENABLED:-true} METRICS_TOKEN: ${METRICS_TOKEN:-} depends_on: api: condition: service_healthy public-api: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1:3002/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 10s expose: - "3002" db-backup: restart: unless-stopped build: context: . dockerfile: docker/db-backup/Dockerfile pull: true environment: NODE_ENV: production DATABASE_URL: ${DATABASE_URL:-postgres://kanera:${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}@postgres:5432/kanera} DB_BACKUPS_ENABLED: ${DB_BACKUPS_ENABLED:-false} DB_BACKUP_TIMES_UTC: ${DB_BACKUP_TIMES_UTC:-00:15,12:15,16:45} DB_BACKUP_RETENTION_DAYS: ${DB_BACKUP_RETENTION_DAYS:-14} DB_BACKUP_S3_BUCKET: ${DB_BACKUP_S3_BUCKET:-${S3_BUCKET:-}} DB_BACKUP_S3_PREFIX: ${DB_BACKUP_S3_PREFIX:-backups/postgres} DB_BACKUP_S3_ENDPOINT: ${DB_BACKUP_S3_ENDPOINT:-${S3_ENDPOINT:-}} DB_BACKUP_ENCRYPTION_PASSPHRASE: ${DB_BACKUP_ENCRYPTION_PASSPHRASE:-} AWS_ACCESS_KEY_ID: ${DB_BACKUP_S3_ACCESS_KEY_ID:-${S3_ACCESS_KEY_ID:-}} AWS_SECRET_ACCESS_KEY: ${DB_BACKUP_S3_SECRET_ACCESS_KEY:-${S3_SECRET_ACCESS_KEY:-}} AWS_DEFAULT_REGION: ${DB_BACKUP_S3_REGION:-${S3_REGION:-}} depends_on: postgres: condition: service_healthy web: restart: unless-stopped build: context: . dockerfile: apps/web/Dockerfile pull: true depends_on: api: condition: service_healthy healthcheck: test: ["CMD-SHELL", "wget -qO- http://127.0.0.1/health >/dev/null"] interval: 10s timeout: 3s retries: 6 start_period: 10s expose: - "80" # Emit one deployment-level success notification after Compose has verified every core service. # Individual processes deliberately do not emit startup alerts: scaled services would spam one # message per replica and a listening port alone does not prove the whole deployment is ready. deployment-ready: restart: "no" build: context: . dockerfile: apps/api/Dockerfile pull: true command: ["node", "--import", "tsx", "src/scripts/send-deployment-ready.ts"] environment: ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} OPS_ALERTS_ENABLED: ${OPS_ALERTS_ENABLED:-true} KANERA_ENVIRONMENT: ${KANERA_ENVIRONMENT:-production} depends_on: api: condition: service_healthy worker: condition: service_healthy public-api: condition: service_healthy mcp: condition: service_healthy web: condition: service_healthy # --------------------------------------------------------------------------- # Monitoring stack. All services below are gated behind the `monitoring` Compose # profile, so plain `docker compose up` and `pnpm dev` never start them. Enable by # setting COMPOSE_PROFILES=monitoring (in .env or the Dokploy environment tab). # Everything here is internal-only (expose, not ports) except Grafana, which you # attach a Dokploy domain to. See DEPLOY.md / DOKPLOY_DEPLOY.md "Monitoring". # --------------------------------------------------------------------------- prometheus: image: prom/prometheus:v3.15.1 restart: unless-stopped profiles: ["monitoring"] # Runs as root so it can both write the scrape token file and own the TSDB volume without a # separate chown step. The token is written to a file (not embedded in prometheus.yml) so the # secret never lives in the repo; scrape jobs reference it via authorization.credentials_file. user: root entrypoint: - /bin/sh - -c - | if [ -z "$${METRICS_TOKEN}" ]; then echo 'METRICS_TOKEN must be set when the monitoring profile is enabled' >&2 exit 1 fi printf '%s' "$${METRICS_TOKEN}" > /etc/prometheus/metrics_token exec /bin/prometheus \ --config.file=/etc/prometheus/prometheus.yml \ --storage.tsdb.path=/prometheus \ --storage.tsdb.retention.time=$${PROMETHEUS_RETENTION:-30d} \ --storage.tsdb.retention.size=$${PROMETHEUS_RETENTION_SIZE:-5GB} \ --web.enable-lifecycle environment: METRICS_TOKEN: ${METRICS_TOKEN:-} # Whichever limit is hit first wins. retention.size is a hard disk ceiling for the TSDB: Prometheus # drops the oldest blocks once exceeded, so the metrics volume cannot grow unbounded. PROMETHEUS_RETENTION: ${PROMETHEUS_RETENTION:-30d} PROMETHEUS_RETENTION_SIZE: ${PROMETHEUS_RETENTION_SIZE:-5GB} volumes: # :Z relabels for SELinux hosts (e.g. Fedora) so the container can read the bind-mounted config; # a no-op on non-SELinux hosts. Same convention as the postgres init.sql mount. - ./docker/monitoring/prometheus.yml:/etc/prometheus/prometheus.yml:ro,Z - kanera_prometheus_data:/prometheus expose: - "9090" # Published on the WireGuard (wg0) host IP so the Prometheus UI (targets, ad-hoc queries) is # reachable over the internal VPN. Defaults to loopback until MONITORING_BIND_IP is set; never # bind monitoring UIs to 0.0.0.0 in production. Same convention as the postgres service. ports: - "${MONITORING_BIND_IP:-127.0.0.1}:${PROMETHEUS_BIND_PORT:-9090}:9090" depends_on: - api grafana: build: context: . dockerfile: docker/monitoring/grafana/Dockerfile pull: true restart: unless-stopped profiles: ["monitoring"] entrypoint: - /bin/sh - -c - | if [ -z "$${GF_SECURITY_ADMIN_PASSWORD}" ]; then echo 'GRAFANA_ADMIN_PASSWORD must be set when the monitoring profile is enabled' >&2 exit 1 fi if [ -z "$${ALERT_WEBHOOK_URL}" ]; then echo 'ALERT_WEBHOOK_URL must be set when the monitoring profile is enabled' >&2 exit 1 fi # Query models do not support Grafana's normal environment interpolation. Render only # numeric replica counts, leaving alert annotations and other Grafana templates untouched. for replicas in "$${API_REPLICAS}" "$${PUBLIC_API_REPLICAS}"; do case "$$replicas" in ''|*[!0-9]*) echo 'Replica counts must be non-negative integers' >&2; exit 1 ;; esac done sed -i \ -e "s/__API_REPLICAS__/$${API_REPLICAS}/g" \ -e "s/__PUBLIC_API_REPLICAS__/$${PUBLIC_API_REPLICAS}/g" \ /etc/grafana/provisioning/alerting/rules.yml || exit 1 exec /run.sh environment: # Keep alert expectations aligned with the service scale, including intentional scale-downs. API_REPLICAS: ${API_REPLICAS:-2} PUBLIC_API_REPLICAS: ${PUBLIC_API_REPLICAS:-1} # Validate at container start so ordinary Compose commands do not require this profile-only # password, while enabling monitoring still fails closed instead of using a known credential. GF_SECURITY_ADMIN_PASSWORD: ${GRAFANA_ADMIN_PASSWORD:-} GF_USERS_ALLOW_SIGN_UP: "false" # Same Slack-compatible alert webhook the app's ops alerts use, fed to Grafana's provisioned # contact point. Delivered via Grafana's Slack integration, which Slack, Zulip's slack_incoming, # Mattermost, etc. all accept. # Grafana 13 rejects an empty Slack URL during provisioning. Require a real destination rather # than silently sending every firing notification to a dead loopback endpoint. ALERT_WEBHOOK_URL: ${ALERT_WEBHOOK_URL:-} # Grafana 13 preinstalls several optional exploration plugins by default. This stack only uses # built-in Prometheus/Loki support, so avoid unpinned downloads and extra startup work. GF_PLUGINS_PREINSTALL_DISABLED: "true" # Set when serving Grafana under a subpath; for a dedicated subdomain leave default. GF_SERVER_ROOT_URL: ${GRAFANA_ROOT_URL:-} volumes: - kanera_grafana_data:/var/lib/grafana expose: - "3000" # Published on the WireGuard (wg0) host IP so the dashboards are reachable over the internal VPN # without any public domain. Set MONITORING_BIND_IP to the server's wg0 address; until then it # stays on 127.0.0.1 (loopback only). Never bind this to 0.0.0.0 in production. ports: - "${MONITORING_BIND_IP:-127.0.0.1}:${GRAFANA_BIND_PORT:-3000}:3000" depends_on: - prometheus - loki loki: image: grafana/loki:3.7.9 restart: unless-stopped profiles: ["monitoring"] command: ["-config.file=/etc/loki/local-config.yaml"] volumes: - ./docker/monitoring/loki-config.yml:/etc/loki/local-config.yaml:ro,Z - kanera_loki_data:/loki expose: - "3100" alloy: image: grafana/alloy:v1.21.3 restart: unless-stopped profiles: ["monitoring"] command: - "run" - "--server.http.listen-addr=0.0.0.0:12345" - "--storage.path=/var/lib/alloy/data" - "/etc/alloy/config.alloy" environment: # Docker discovery is host-wide. Set this explicitly when a deployment platform assigns a # project name other than the repository default so unrelated containers are never ingested. KANERA_COMPOSE_PROJECT: ${COMPOSE_PROJECT_NAME:-kanera} volumes: - ./docker/monitoring/alloy/config.alloy:/etc/alloy/config.alloy:ro,Z # Read-only access to the Docker socket so Alloy can discover containers and tail their logs. # No :z/:Z here — never relabel the host's shared Docker socket. - /var/run/docker.sock:/var/run/docker.sock:ro - kanera_alloy_data:/var/lib/alloy/data expose: - "12345" depends_on: - loki # Creates the least-privilege `kanera_exporter` role before the exporter tries to log in. Follows # the same one-shot pattern as `migrate`: the SQL is idempotent, so this runs to completion on # every monitoring deploy and is a no-op once the role exists. Provisioning it here rather than in # docker/postgres/init.sql matters because init.sql only runs when the data directory is first # created — an existing deployment enabling monitoring would never get the role. postgres-exporter-init: image: postgres:18.6-alpine restart: "no" profiles: ["monitoring"] environment: PGPASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} # Delivered as a connection-time GUC rather than a psql variable so the exporter password is # never part of a SQL statement — see the comment in exporter-role.sql for why that matters. PGOPTIONS: -c kanera.exporter_password=${POSTGRES_EXPORTER_PASSWORD:-${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}} command: - "psql" - "--host=postgres" - "--username=kanera" - "--dbname=kanera" - "--no-password" - "--set=ON_ERROR_STOP=1" - "--file=/sql/exporter-role.sql" volumes: - ./docker/monitoring/exporter-role.sql:/sql/exporter-role.sql:ro,Z depends_on: postgres: condition: service_healthy postgres-exporter: image: quay.io/prometheuscommunity/postgres-exporter:v0.20.1 restart: unless-stopped profiles: ["monitoring"] # pg_stat_statements comes from the exporter's built-in collector. The older # PG_EXPORTER_EXTEND_QUERY_PATH mechanism is deprecated upstream and logs a warning on every # start; it will be removed in some future 0.x release, and this exporter floats no tag to # cushion that. Metric names differ from the custom-query version # (calls -> calls_total, total_exec_time_seconds -> seconds_total) and the query text moves to a # separate pg_stat_statements_query_id series keyed by queryid — the query-performance dashboard # joins the two on that label. command: - "--collector.stat_statements" # Without include_query the exporter emits queryids only, which makes the dashboard unreadable. - "--collector.stat_statements.include_query" # Matches the truncation the previous custom query applied via left(query, 200). - "--collector.stat_statements.query_length=200" # Bounds series cardinality: one queryid can produce six metric families. The old custom query # had no limit at all and exported every statement Postgres had ever seen. - "--collector.stat_statements.limit=250" # Drops the exporter's own pg_stat_* scrape queries, which otherwise dominate the slowest-query # panels. See docker/monitoring/exporter-role.sql for why this must filter by role, not text. - "--collector.stat_statements.exclude_users=kanera_exporter" environment: DATA_SOURCE_NAME: ${POSTGRES_EXPORTER_DSN:-postgresql://kanera_exporter:${POSTGRES_EXPORTER_PASSWORD:-${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env}}@postgres:5432/kanera?sslmode=disable} # Hide Postgres system databases from dashboards — only the app database is relevant. PG_EXPORTER_EXCLUDE_DATABASES: template0,template1,postgres expose: - "9187" depends_on: postgres: condition: service_healthy postgres-exporter-init: condition: service_completed_successfully cadvisor: # cAdvisor releases moved from gcr.io to GHCR starting with v0.53.0. image: ghcr.io/google/cadvisor:v0.60.6 restart: unless-stopped profiles: ["monitoring"] # cAdvisor needs broad read access to the host's cgroup/container state for per-container CPU/mem. privileged: true command: # Prometheus scrapes every 15 seconds, so one-second container housekeeping only adds overhead. # Preserve cAdvisor's default metric families so existing/community dashboards remain compatible. - "--docker_only=true" - "--housekeeping_interval=15s" - "--max_housekeeping_interval=30s" volumes: - /:/rootfs:ro - /var/run:/var/run:ro - /sys:/sys:ro - /var/lib/docker/:/var/lib/docker:ro - /dev/disk/:/dev/disk:ro expose: - "8080" node-exporter: image: prom/node-exporter:v1.12.1 restart: unless-stopped profiles: ["monitoring"] command: - "--path.rootfs=/host" pid: host volumes: - /:/host:ro,rslave expose: - "9100" redis-exporter: image: oliver006/redis_exporter:v1.97.0 restart: unless-stopped profiles: ["monitoring"] environment: REDIS_ADDR: ${REDIS_URL:-redis://valkey:6379} expose: - "9121" depends_on: valkey: condition: service_healthy volumes: kanera_pgdata: kanera_uploads: kanera_prometheus_data: kanera_grafana_data: kanera_loki_data: kanera_alloy_data: