# ───────────────────────────────────────────────────────────── # Self-hosting stack: postgres + minio + api + worker + bot + web. # Coolify uses docker-compose.coolify.yml; local dev uses docker-compose.dev.yml # (backing services only). # # cp .env.example .env # fill in the required values below # docker compose up -d # # Required in .env (the stack refuses to start without them): # POSTGRES_PASSWORD, BETTER_AUTH_SECRET, APP_ENCRYPTION_KEY, # S3_ACCESS_KEY_ID, S3_SECRET_ACCESS_KEY, API_URL, APP_URL # Generate each secret with: openssl rand -base64 32 # # The four services run from images published on each release; update with # docker compose pull && docker compose up -d # VERSION in .env pins a release (defaults to the latest one). To run this # checkout instead of the published images, build them: # docker compose up -d --build # ───────────────────────────────────────────────────────────── name: itsaplan services: postgres: image: postgres:17-alpine restart: unless-stopped environment: POSTGRES_USER: ${POSTGRES_USER:-itsaplan} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set POSTGRES_PASSWORD in .env} 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. # Both the S3 API (:9000) and the console (:9001) stay inside the compose # network: the api streams attachment bytes through itself, so no port is # published. Administer through mc from inside the network or an SSH tunnel. minio: image: quay.io/minio/minio:latest restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID:?set S3_ACCESS_KEY_ID in .env} MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY in .env} volumes: - minio-data:/data healthcheck: test: ['CMD', 'mc', 'ready', 'local'] interval: 15s timeout: 5s retries: 5 start_period: 20s # One-shot: waits for MinIO, creates the attachments bucket, then exits. minio-init: image: quay.io/minio/mc:latest restart: 'no' depends_on: - minio environment: MINIO_ROOT_USER: ${S3_ACCESS_KEY_ID} MINIO_ROOT_PASSWORD: ${S3_SECRET_ACCESS_KEY} S3_BUCKET: ${S3_BUCKET:-planner-attachments} entrypoint: > /bin/sh -c " until mc alias set local http://minio:9000 \"$$MINIO_ROOT_USER\" \"$$MINIO_ROOT_PASSWORD\"; do echo 'waiting for minio...'; sleep 2; done; mc mb --ignore-existing local/\"$$S3_BUCKET\"; echo 'bucket ready: '\"$$S3_BUCKET\"; " # Applies database migrations on startup, then serves the API. api: image: ghcr.io/croffasia/itsaplan-api:${VERSION:-latest} build: context: . dockerfile: apps/api/Dockerfile restart: unless-stopped depends_on: postgres: condition: service_healthy minio-init: condition: service_completed_successfully environment: NODE_ENV: production API_PORT: 3000 DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-itsaplan} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set BETTER_AUTH_SECRET in .env} # Encrypts stored provider credentials at rest (AES-256-GCM). Changing it # makes existing stored credentials undecryptable. APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?set APP_ENCRYPTION_KEY in .env} # Public origins, each entered once. API_URL is the api origin (auth handler, # email links); APP_URL is the web origin (CORS, passkey, email links). API_URL: ${API_URL:?public origin of the api, e.g. https://api.example.com} APP_URL: ${APP_URL:?public origin of the web app, e.g. https://app.example.com} # Set COOKIE_DOMAIN only to override the parent domain derived from # APP_URL (multi-label TLDs, deep subdomains). COOKIE_DOMAIN: ${COOKIE_DOMAIN:-} S3_ENDPOINT: http://minio:9000 S3_BUCKET: ${S3_BUCKET:-planner-attachments} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} 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:-} # How long the pre-migration 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 migrate.ts takes before it applies a release's migrations, and # keeps it for BACKUP_RETENTION_DAYS (30) so a downgrade has something to restore. volumes: - db-backups:/backups # start_period covers the backup and the migrations the container applies before # it listens. healthcheck: test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3000/'] interval: 15s timeout: 5s retries: 5 start_period: 180s ports: - '${API_PORT:-3000}:3000' # Webhook delivery, notification delivery, agent schedules, and agent runs. # Shares the database with the api. No public ports. worker: image: ghcr.io/croffasia/itsaplan-worker:${VERSION:-latest} build: context: . dockerfile: apps/worker/Dockerfile restart: unless-stopped depends_on: postgres: condition: service_healthy api: condition: service_started environment: NODE_ENV: production DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${POSTGRES_PASSWORD}@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: ${APP_ENCRYPTION_KEY:?set APP_ENCRYPTION_KEY in .env} # 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:-} # Optional tuning lives in apps/worker/src/config.ts. # Telegram bot: long polling for the instance bot, completing the account links # users start from their profile. The bot token is stored in the database and # edited in god mode, so it is not an environment variable here. # # 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: image: ghcr.io/croffasia/itsaplan-bot:${VERSION:-latest} build: context: . dockerfile: apps/bot/Dockerfile restart: unless-stopped depends_on: postgres: condition: service_healthy environment: NODE_ENV: production DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-itsaplan} APP_ENCRYPTION_KEY: ${APP_ENCRYPTION_KEY:?set APP_ENCRYPTION_KEY in .env} # The origins are read at startup and handed to the browser by the server render, # so the image carries none of them and a changed API_URL only needs a restart. web: image: ghcr.io/croffasia/itsaplan-web:${VERSION:-latest} 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. API_URL: ${API_URL:?public origin of the api, e.g. https://api.example.com} # Where web itself reaches the api: it streams avatars and attachments from # there (/media) so they are served from the web origin. 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 ports: - '${WEB_PORT:-3001}:3001' volumes: postgres-data: minio-data: db-backups: