# ============================================================================= # GitTerm — self-hosted stack # ============================================================================= # Runs the full platform (Postgres, Redis, API server, listener, worker, web, # and the Caddy edge proxy) from the published GHCR images. # # ROUTING_MODE=path (default) serves everything from one origin, no DNS setup. # ROUTING_MODE=subdomain serves workspaces at -. # and needs wildcard DNS (*.PUBLIC_HOST) plus a wildcard TLS certificate on # whatever terminates TLS in front of the proxy service. # # cp .env.selfhost.example .env.selfhost # then fill in the secrets # docker compose --env-file .env.selfhost -f docker-compose.selfhost.yml up -d # # Migrations, catalog seed and admin bootstrap run automatically inside the # server container on every start (see apps/server/docker-entrypoint.sh). # Set SKIP_MIGRATIONS/SKIP_SEED/SKIP_ADMIN_SEED=true to manage them yourself. # # To build from source instead of pulling images: # docker compose --env-file .env.selfhost -f docker-compose.selfhost.yml up -d --build # # NOTE: docker-compose.yml (no suffix) is the infra-only file used for local # development against natively-run apps. This file is independent of it. # ============================================================================= name: gitterm-selfhost x-internal-env: &internal-env SERVER_URL: http://server:8080/api INTERNAL_API_KEY: ${INTERNAL_API_KEY:?INTERNAL_API_KEY is required} DEPLOYMENT_MODE: self-hosted NODE_ENV: production ENCRYPTION_MASTER_KEY: ${ENCRYPTION_MASTER_KEY:?ENCRYPTION_MASTER_KEY is required} ENCRYPTION_MASTER_KEY_ID: ${ENCRYPTION_MASTER_KEY_ID:-primary} ENCRYPTION_MASTER_KEYS: ${ENCRYPTION_MASTER_KEYS:-} ENCRYPTION_WRITE_VERSION: ${ENCRYPTION_WRITE_VERSION:-v2} services: postgres: image: postgres:18 environment: POSTGRES_DB: ${POSTGRES_DB:-gitterm} POSTGRES_USER: ${POSTGRES_USER:-gitterm} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD is required} volumes: - postgres_data:/var/lib/postgresql healthcheck: test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped redis: image: redis:7-alpine command: redis-server --appendonly yes volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 restart: unless-stopped server: image: ${GITTERM_IMAGE_PREFIX:-ghcr.io/opeoginni/gitterm}-server:${GITTERM_IMAGE_TAG:-latest} build: context: . dockerfile: apps/server/Dockerfile depends_on: postgres: condition: service_healthy redis: condition: service_healthy environment: <<: *internal-env PORT: 8080 DATABASE_URL: postgresql://${POSTGRES_USER:-gitterm}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-gitterm} REDIS_URL: redis://redis:6379 ROUTING_MODE: ${ROUTING_MODE:-path} BASE_URL: ${PUBLIC_URL:?PUBLIC_URL is required} BASE_DOMAIN: ${PUBLIC_HOST:?PUBLIC_HOST is required} CORS_ORIGIN: ${PUBLIC_URL} BETTER_AUTH_URL: ${PUBLIC_URL}/api API_URL: ${PUBLIC_URL}/api WORKSPACE_API_URL: ${PUBLIC_URL}/api/trpc DEVICE_CODE_VERIFICATION_URI: ${PUBLIC_URL}/device BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?BETTER_AUTH_SECRET is required} AUTH_ENCRYPT_OAUTH_TOKENS: ${AUTH_ENCRYPT_OAUTH_TOKENS:-false} WORKSPACE_JWT_SECRET: ${WORKSPACE_JWT_SECRET:?WORKSPACE_JWT_SECRET is required} ADMIN_EMAIL: ${ADMIN_EMAIL:-} ADMIN_PASSWORD: ${ADMIN_PASSWORD:-} GITHUB_APP_CLIENT_ID: ${GITHUB_APP_CLIENT_ID:-} GITHUB_APP_CLIENT_SECRET: ${GITHUB_APP_CLIENT_SECRET:-} ENABLE_IDLE_REAPING: ${ENABLE_IDLE_REAPING:-true} EMAIL_PROVIDER: ${EMAIL_PROVIDER:-auto} EMAIL_FROM: ${EMAIL_FROM:-GitTerm } RESEND_API_KEY: ${RESEND_API_KEY:-} SMTP_HOST: ${SMTP_HOST:-} SMTP_PORT: ${SMTP_PORT:-587} SMTP_USER: ${SMTP_USER:-} SMTP_PASS: ${SMTP_PASS:-} SKIP_MIGRATIONS: ${SKIP_MIGRATIONS:-false} SKIP_SEED: ${SKIP_SEED:-false} SKIP_ADMIN_SEED: ${SKIP_ADMIN_SEED:-false} healthcheck: test: - CMD - bun - -e - "fetch('http://localhost:8080/').then((r) => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" interval: 15s timeout: 5s retries: 10 start_period: 90s restart: unless-stopped listener: image: ${GITTERM_IMAGE_PREFIX:-ghcr.io/opeoginni/gitterm}-listener:${GITTERM_IMAGE_TAG:-latest} build: context: . dockerfile: apps/listener/Dockerfile depends_on: server: condition: service_healthy environment: <<: *internal-env PORT: 8080 WORKSPACE_JWT_SECRET: ${WORKSPACE_JWT_SECRET:?WORKSPACE_JWT_SECRET is required} BASE_DOMAIN: ${PUBLIC_HOST} CORS_ORIGIN: ${PUBLIC_URL} restart: unless-stopped worker: image: ${GITTERM_IMAGE_PREFIX:-ghcr.io/opeoginni/gitterm}-idle-reaper-worker:${GITTERM_IMAGE_TAG:-latest} build: context: . dockerfile: apps/worker/Dockerfile depends_on: server: condition: service_healthy environment: <<: *internal-env ENABLE_IDLE_REAPING: ${ENABLE_IDLE_REAPING:-true} # Minutes between reap passes. Must be >0 here: the worker loops in-process # because Compose has no external cron. 0 = run once and exit (Railway Cron mode). REAP_INTERVAL_MINUTES: ${REAP_INTERVAL_MINUTES:-10} restart: unless-stopped web: image: ${GITTERM_IMAGE_PREFIX:-ghcr.io/opeoginni/gitterm}-web:${GITTERM_IMAGE_TAG:-latest} build: context: . dockerfile: apps/web/Dockerfile depends_on: server: condition: service_healthy environment: PORT: 8080 HOSTNAME: 0.0.0.0 NEXT_PUBLIC_BASE_DOMAIN: ${PUBLIC_HOST} NEXT_PUBLIC_SERVER_URL: ${PUBLIC_URL}/api NEXT_PUBLIC_AUTH_URL: ${PUBLIC_URL} NEXT_PUBLIC_LISTENER_URL: ${PUBLIC_URL}/listener NEXT_PUBLIC_DEPLOYMENT_MODE: self-hosted NEXT_PUBLIC_ROUTING_MODE: ${ROUTING_MODE:-path} NEXT_PUBLIC_ENABLE_BILLING: "false" restart: unless-stopped proxy: image: ${GITTERM_IMAGE_PREFIX:-ghcr.io/opeoginni/gitterm}-proxy:${GITTERM_IMAGE_TAG:-latest} build: context: . dockerfile: apps/proxy/Dockerfile depends_on: - server - web - listener ports: - "${GITTERM_PORT:-8888}:80" environment: ROUTING_MODE: ${ROUTING_MODE:-path} BASE_DOMAIN: ${PUBLIC_HOST} WEB_URL: web:8080 SERVER_URL: server:8080 LISTENER_URL: listener:8080 INTERNAL_API_KEY: ${INTERNAL_API_KEY} restart: unless-stopped volumes: postgres_data: redis_data: