version: '3.8' # Merlin Self-Hosting Docker Compose # This setup runs all Merlin services in a single container with nginx reverse proxy # # Uses subdomain-based routing: # - api.example.com -> API Server # - app.example.com -> Web Dashboard # - docs.example.com -> Documentation # # Usage: # 1. Copy .env.self-host.example to .env and configure # 2. Set your domain names (API_DOMAIN, DASHBOARD_DOMAIN, etc.) # 3. Run: docker compose up -d # 4. Access your instance at the configured domains services: # ============================================ # Infrastructure Services # ============================================ postgres: image: postgres:16-alpine container_name: merlin-postgres restart: unless-stopped environment: POSTGRES_DB: merlin POSTGRES_USER: merlin POSTGRES_PASSWORD: ${DB_PASSWORD:-changeme123} volumes: - postgres_data:/var/lib/postgresql/data healthcheck: test: [ "CMD-SHELL", "pg_isready -U merlin" ] interval: 10s timeout: 5s retries: 5 networks: - merlin redis: image: redis:7-alpine container_name: merlin-redis restart: unless-stopped command: redis-server --appendonly yes volumes: - redis_data:/data healthcheck: test: [ "CMD", "redis-cli", "ping" ] interval: 10s timeout: 5s retries: 5 networks: - merlin minio: image: minio/minio:latest container_name: merlin-minio restart: unless-stopped command: server /data --console-address ":9001" environment: MINIO_ROOT_USER: ${MINIO_ROOT_USER:-merlin} MINIO_ROOT_PASSWORD: ${MINIO_ROOT_PASSWORD:-merlinminiopass} volumes: - minio_data:/data ports: # Expose Minio API (for S3 operations) and Console (for web UI) - "${MINIO_API_PORT:-9000}:9000" - "${MINIO_CONSOLE_PORT:-9001}:9001" healthcheck: test: [ "CMD", "curl", "-f", "http://localhost:9000/minio/health/live" ] interval: 30s timeout: 20s retries: 3 networks: - merlin ntfy: image: binwiederhier/ntfy:latest container_name: merlin-ntfy restart: unless-stopped command: serve environment: - TZ=UTC volumes: - ntfy_cache:/var/cache/ntfy - ntfy_etc:/etc/ntfy ports: # Expose ntfy web UI and API - "${NTFY_PORT:-8080}:80" healthcheck: test: [ "CMD-SHELL", "wget -q --tries=1 http://localhost:80/v1/health -O - | grep -Eo '\"healthy\"\\s*:\\s*true' || exit 1" ] interval: 30s timeout: 10s retries: 3 start_period: 10s networks: - merlin # ============================================ # Merlin Application # ============================================ merlin: image: ghcr.io/merlin/merlin:latest container_name: merlin restart: unless-stopped environment: # Service mode SERVICE: all NODE_ENV: production # Database DATABASE_URL: postgresql://merlin:${DB_PASSWORD:-changeme123}@postgres:5432/merlin DIRECT_DATABASE_URL: postgresql://merlin:${DB_PASSWORD:-changeme123}@postgres:5432/merlin # Redis REDIS_URL: redis://redis:6379 # Security JWT_SECRET: ${JWT_SECRET} # Nginx configuration NGINX_PORT: ${NGINX_PORT:-80} # Domain configuration (subdomain-based routing) # Each service gets its own subdomain API_DOMAIN: ${API_DOMAIN:-api.localhost} DASHBOARD_DOMAIN: ${DASHBOARD_DOMAIN:-app.localhost} WIKI_DOMAIN: ${WIKI_DOMAIN:-docs.localhost} # Protocol configuration (set to true for HTTPS in production) USE_HTTPS: ${USE_HTTPS:-false} # Application URLs (auto-configured by setup-nginx.sh from domains above) # You can override these if needed API_URI: ${API_URI:-} DASHBOARD_URI: ${DASHBOARD_URI:-} WIKI_URI: ${WIKI_URI:-} # AWS SES (for sending emails) AWS_SES_REGION: ${AWS_SES_REGION} AWS_SES_ACCESS_KEY_ID: ${AWS_SES_ACCESS_KEY_ID} AWS_SES_SECRET_ACCESS_KEY: ${AWS_SES_SECRET_ACCESS_KEY} SES_CONFIGURATION_SET: ${SES_CONFIGURATION_SET} SES_CONFIGURATION_SET_NO_TRACKING: ${SES_CONFIGURATION_SET_NO_TRACKING:-} # Custom MAIL FROM subdomain (defaults to 'merlin'; override when # merlin. is already used for something else, e.g. a CDN) MAIL_FROM_SUBDOMAIN: ${MAIL_FROM_SUBDOMAIN:-} # Optional: OAuth GITHUB_OAUTH_CLIENT: ${GITHUB_OAUTH_CLIENT:-} GITHUB_OAUTH_SECRET: ${GITHUB_OAUTH_SECRET:-} GOOGLE_OAUTH_CLIENT: ${GOOGLE_OAUTH_CLIENT:-} GOOGLE_OAUTH_SECRET: ${GOOGLE_OAUTH_SECRET:-} # Optional: Stripe STRIPE_SK: ${STRIPE_SK:-} STRIPE_WEBHOOK_SECRET: ${STRIPE_WEBHOOK_SECRET:-} STRIPE_PRICE_ONBOARDING: ${STRIPE_PRICE_ONBOARDING:-} STRIPE_PRICE_EMAIL_USAGE: ${STRIPE_PRICE_EMAIL_USAGE:-} STRIPE_METER_EVENT_NAME: ${STRIPE_METER_EVENT_NAME:-emails} # S3-compatible storage (Minio) S3_ENDPOINT: ${S3_ENDPOINT:-http://minio:9000} S3_ACCESS_KEY_ID: ${S3_ACCESS_KEY_ID:-merlin} S3_ACCESS_KEY_SECRET: ${S3_ACCESS_KEY_SECRET:-merlinminiopass} S3_BUCKET: ${S3_BUCKET:-uploads} S3_PUBLIC_URL: ${S3_PUBLIC_URL:-http://localhost:9000/uploads} S3_FORCE_PATH_STYLE: ${S3_FORCE_PATH_STYLE:-true} # Notifications (ntfy.sh) NTFY_URL: ${NTFY_URL:-http://ntfy/merlin-notifications} # Platform emails MERLIN_API_KEY: ${MERLIN_API_KEY:-} MERLIN_FROM_ADDRESS: ${MERLIN_FROM_ADDRESS:-} # Security AUTO_PROJECT_DISABLE: ${AUTO_PROJECT_DISABLE:-false} ALLOW_SHARED_DOMAINS: ${ALLOW_SHARED_DOMAINS:-false} # Self-hosting user management (documented in .env.self-host.example # and read by apps/api/src/app/constants.ts at import time) DISABLE_SIGNUPS: ${DISABLE_SIGNUPS:-false} DISABLE_PASSWORD_AUTH: ${DISABLE_PASSWORD_AUTH:-false} # Explicit SES sending rate (avoid silent fallback to 14/sec when # ses:GetSendQuota is denied or transiently fails at worker startup) EMAIL_RATE_LIMIT_PER_SECOND: ${EMAIL_RATE_LIMIT_PER_SECOND:-} volumes: # Persistent storage for application data - merlin_data:/app/data depends_on: postgres: condition: service_healthy redis: condition: service_healthy minio: condition: service_healthy ntfy: condition: service_healthy healthcheck: # Check nginx is running and responding test: [ "CMD", "curl", "-f", "http://localhost:80/" ] interval: 30s timeout: 10s retries: 3 start_period: 60s networks: - merlin volumes: postgres_data: driver: local redis_data: driver: local minio_data: driver: local merlin_data: driver: local ntfy_cache: driver: local ntfy_etc: driver: local networks: merlin: driver: bridge