Ctrl K
ComunidadGuías…

docker-compose.yml

Rama main · réplica de lectura del repositorio original.

Archivo original ↗Preguntar a KiBot
# 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: