# ───────────────────────────────────────────────────────────── # Coolify stack: postgres + rustfs + api + worker + bot + web. # In Coolify: New Resource → Docker Compose → point to this file, then set the # environment variables (see .env.example) in the UI. # # Every service is built from the checkout Coolify clones. To run the images published # on each release instead, use docker-compose.coolify-images.yml. # # For self-hosting anywhere else, use docker-compose.yml. # # SERVICE_PASSWORD_* / SERVICE_URL_* are Coolify magic variables: generated and # persisted on first deploy, stable across deploys. SERVICE_URL_* come from the # Domains fields, so the public URLs live in one place. # # Coolify reports the worst container health as the resource status, so a service # without a healthcheck holds the whole stack at "running (unknown)". Services that # answer no requests are taken out of that aggregate with `exclude_from_hc: true` # (a Coolify key, stripped before compose runs) or, for one-shot containers, by # `restart: 'no'`, which it treats the same way. # ───────────────────────────────────────────────────────────── services: postgres: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-itsaplan} POSTGRES_PASSWORD: ${SERVICE_PASSWORD_POSTGRES} POSTGRES_DB: ${POSTGRES_DB:-itsaplan} volumes: - postgres-data:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U ${POSTGRES_USER:-itsaplan} -d ${POSTGRES_DB:-itsaplan}'] interval: 5s timeout: 5s retries: 10 # S3-compatible object store for issue attachments (RustFS). # Both the S3 API (:9000) and the console (:9001) are internal only — the api # streams attachment bytes over the internal network, so no port is published. # Administer through an S3 client from inside the network or an SSH tunnel. # # The service, its volume and the SERVICE_*_MINIO credentials keep the names of the # MinIO stack this replaced: a renamed service would leave the old MinIO container # running on the same volume, a renamed volume would start empty, and renamed # credentials would be generated anew. RustFS reads the files MinIO wrote in place; # MinIO wrote them as root, so the container runs as root to keep them writable. minio: image: rustfs/rustfs:1.0.0 restart: unless-stopped user: '0:0' environment: RUSTFS_ACCESS_KEY: ${SERVICE_USER_MINIO} RUSTFS_SECRET_KEY: ${SERVICE_PASSWORD_MINIO} volumes: - minio-data:/data healthcheck: test: ['CMD', 'curl', '-fsS', '-o', '/dev/null', 'http://127.0.0.1:9000/health'] interval: 15s timeout: 5s retries: 5 start_period: 20s # One-shot: creates the attachments bucket, then exits. A PUT on a bucket that # already exists succeeds, so it runs on every start. # restart "no" is explicit: it exits 0 by design, and platforms that inject # restart: unless-stopped (e.g. Coolify) would otherwise loop it forever and # trip their restart-count limit, tearing the whole stack down. minio-init: image: rustfs/rustfs:1.0.0 restart: 'no' depends_on: minio: condition: service_healthy environment: S3_ACCESS_KEY_ID: ${SERVICE_USER_MINIO} S3_SECRET_ACCESS_KEY: ${SERVICE_PASSWORD_MINIO} S3_BUCKET: ${S3_BUCKET:-planner-attachments} entrypoint: > /bin/sh -c " curl -fsS -X PUT --aws-sigv4 aws:amz:us-east-1:s3 --user \"$$S3_ACCESS_KEY_ID:$$S3_SECRET_ACCESS_KEY\" http://minio:9000/\"$$S3_BUCKET\" && echo 'bucket ready: '\"$$S3_BUCKET\" " # One-shot: dumps the database and applies the migrations, then exits. api, worker # and bot start only after it succeeds, so none of them runs against an old schema. # restart 'no' keeps a finished run from being started again. migrate: build: context: . dockerfile: apps/api/Dockerfile restart: 'no' exclude_from_hc: true command: ['bun', 'run', 'packages/db/src/migrate.ts'] depends_on: postgres: condition: service_healthy environment: DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-itsaplan} # How long the dumps are kept, and the switch that upgrades without one. # BACKUP_DIR is not among them: the volume below pins it. BACKUP_RETENTION_DAYS: ${BACKUP_RETENTION_DAYS:-} SKIP_PRE_MIGRATION_BACKUP: ${SKIP_PRE_MIGRATION_BACKUP:-} # Holds the dump taken before a release's migrations, kept for # BACKUP_RETENTION_DAYS (30) so a downgrade has something to restore. volumes: - db-backups:/backups api: build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped # The image's own CMD migrates first; here the migrate service has done that. command: ['bun', 'run', 'apps/api/src/index.ts'] depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully minio: condition: service_started environment: NODE_ENV: production API_PORT: 3000 DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-itsaplan} BETTER_AUTH_SECRET: ${SERVICE_PASSWORD_64_BETTERAUTH} # Encrypts stored AI provider API keys at rest (AES-256-GCM). Changing it makes # existing stored credentials undecryptable. APP_ENCRYPTION_KEY: ${SERVICE_PASSWORD_64_ENCRYPTION} # API_URL is the api origin; APP_URL is the web origin. API_URL: ${SERVICE_URL_API:-http://localhost:3000} APP_URL: ${SERVICE_URL_WEB:-http://localhost:3001} # To override the cookie parent domain (multi-label TLDs / deep subdomains), # add a COOKIE_DOMAIN env in Coolify — the code reads it if present. # Where the client IP for the rate limit is read; empty reads X-Forwarded-For. CLIENT_IP_HEADER: ${CLIENT_IP_HEADER:-} TRUSTED_PROXIES: ${TRUSTED_PROXIES:-} # Public origin for incoming Git webhooks when API_URL is private; empty uses API_URL. GIT_WEBHOOK_BASE_URL: ${GIT_WEBHOOK_BASE_URL:-} S3_ENDPOINT: http://minio:9000 S3_BUCKET: ${S3_BUCKET:-planner-attachments} S3_ACCESS_KEY_ID: ${SERVICE_USER_MINIO} S3_SECRET_ACCESS_KEY: ${SERVICE_PASSWORD_MINIO} S3_REGION: ${S3_REGION:-us-east-1} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-} # Hosts trusted even though they resolve privately. Also governs webhook URL # validation and attachment import. SSRF_ALLOWED_HOSTS: ${SSRF_ALLOWED_HOSTS:-} healthcheck: test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3000/'] interval: 15s timeout: 5s retries: 5 start_period: 30s # Set API_PORT in Coolify if 3000 is taken on the host. ports: - '${API_PORT:-3000}:3000' # Background worker for webhook delivery, agent schedules, and autonomous agent # runs. Shares the database with the api. No public ports, so nothing to probe. worker: build: context: . dockerfile: apps/worker/Dockerfile restart: unless-stopped exclude_from_hc: true depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully environment: NODE_ENV: production DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-itsaplan} # Notification delivery sends email and Telegram messages itself, with the # provider credentials stored in the database — encrypted with this key, the # same value the api uses. APP_ENCRYPTION_KEY: ${SERVICE_PASSWORD_64_ENCRYPTION} # Anonymous telemetry (TELEMETRY.md). Either variable, set to anything but 0, # turns it off. TELEMETRY_DISABLED: ${TELEMETRY_DISABLED:-} DO_NOT_TRACK: ${DO_NOT_TRACK:-} TELEMETRY_DEBUG: ${TELEMETRY_DEBUG:-} # The same list the api reads. This process delivers webhooks to those hosts. SSRF_ALLOWED_HOSTS: ${SSRF_ALLOWED_HOSTS:-} # A downloaded Plane import attachment is stored here, the same bucket the # api's own uploads use. S3_ENDPOINT: http://minio:9000 S3_BUCKET: ${S3_BUCKET:-planner-attachments} S3_ACCESS_KEY_ID: ${SERVICE_USER_MINIO} S3_SECRET_ACCESS_KEY: ${SERVICE_PASSWORD_MINIO} S3_REGION: ${S3_REGION:-us-east-1} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-} # Optional tuning — defaults live in apps/worker/src/config.ts: # WEBHOOK_POLL_INTERVAL_MS, WEBHOOK_BATCH_SIZE, WEBHOOK_TIMEOUT_MS, # WEBHOOK_MAX_ATTEMPTS, WEBHOOK_DISABLE_THRESHOLD, WEBHOOK_LEASE_SECONDS, # WEBHOOK_CLEANUP_DAYS, WEBHOOK_CLEANUP_EVERY_TICKS. # Agent schedules: AGENT_RUN_POLL_INTERVAL_MS. The api runs the queued runs # and reads the other AGENT_RUN_* values. # Telegram bot: long polling for the instance bot, completing the account links # users start from their profile. It reads the bot token and writes the links in the # database, and holds no api credential — no public ports either. # # Must stay at one replica: Telegram hands each getUpdates call to a single caller, # so a second instance would take updates away from the first. bot: build: context: . dockerfile: apps/bot/Dockerfile restart: unless-stopped exclude_from_hc: true depends_on: postgres: condition: service_healthy migrate: condition: service_completed_successfully environment: NODE_ENV: production DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${SERVICE_PASSWORD_POSTGRES}@postgres:5432/${POSTGRES_DB:-itsaplan} APP_ENCRYPTION_KEY: ${SERVICE_PASSWORD_64_ENCRYPTION} # The bot token itself is not set here: it is stored in the database and edited # in god mode, so the service picks up a change without a redeploy. # Optional tuning — defaults live in apps/bot/src/config.ts: # BOT_CONFIG_POLL_INTERVAL_MS, BOT_CONFIG_RETRY_INTERVAL_MS. web: build: context: . dockerfile: apps/web/Dockerfile restart: unless-stopped depends_on: - api environment: NODE_ENV: production PORT: 3001 HOSTNAME: 0.0.0.0 # The api origin the browser uses, from Coolify's Domain for the api service. # web reaches the api at the same address to stream avatars and attachments # (/media), so they are served from the web origin. API_URL: ${SERVICE_URL_API:-http://localhost:3000} SERVICE_URL_API: ${SERVICE_URL_API:-http://api:3000} # Legal document URLs shown on the logged-out screens; empty hides the notice. PRIVACY_URL: ${PRIVACY_URL:-} TERMS_URL: ${TERMS_URL:-} # /login renders without a session, and without reaching the api. healthcheck: test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3001/login'] interval: 15s timeout: 5s retries: 5 start_period: 20s # Set WEB_PORT in Coolify if 3001 is taken on the host. ports: - '${WEB_PORT:-3001}:3001' volumes: postgres-data: minio-data: db-backups: