docker-compose.yml
Rama main · réplica de lectura del repositorio original.
# 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: