# ───────────────────────────────────────────────────────────── # Self-hosting stack: postgres + rustfs + 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 (RustFS). # 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 an S3 client from inside the network or an SSH tunnel. # # The service and volume keep the names of the MinIO stack this replaced: a renamed # service would leave the old MinIO container running on the same volume, and a # renamed volume would start empty. 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: ${S3_ACCESS_KEY_ID:?set S3_ACCESS_KEY_ID in .env} RUSTFS_SECRET_KEY: ${S3_SECRET_ACCESS_KEY:?set S3_SECRET_ACCESS_KEY in .env} 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. minio-init: image: rustfs/rustfs:1.0.0 restart: 'no' depends_on: minio: condition: service_healthy environment: S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID} S3_SECRET_ACCESS_KEY: ${S3_SECRET_ACCESS_KEY} 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: image: ghcr.io/croffasia/itsaplan-api:${VERSION:-latest} build: context: . dockerfile: apps/api/Dockerfile restart: 'no' command: ['bun', 'run', 'packages/db/src/migrate.ts'] depends_on: postgres: condition: service_healthy environment: DATABASE_URL: postgres://${POSTGRES_USER:-itsaplan}:${POSTGRES_PASSWORD}@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: image: ghcr.io/croffasia/itsaplan-api:${VERSION:-latest} 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-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:-} # 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: ${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:-} healthcheck: test: ['CMD', 'wget', '-q', '-O', '/dev/null', 'http://127.0.0.1:3000/'] interval: 15s timeout: 5s retries: 5 start_period: 30s 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 migrate: condition: service_completed_successfully 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:-} # 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: ${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:-} # 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 migrate: condition: service_completed_successfully 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: