version: "3.8" services: # Edge reverse proxy — the only service publishing :80/:443 publicly. # Routes by Host header to internal `api` and `admin` services over the # overlay network. Runs one replica per node via ingress mesh, so any node # can terminate incoming traffic. caddy: image: caddy:2-alpine ports: - target: 80 published: 80 protocol: tcp mode: ingress - target: 443 published: 443 protocol: tcp mode: ingress configs: - source: caddyfile target: /etc/caddy/Caddyfile mode: 0444 volumes: - caddy_data:/data - caddy_config:/config healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1/"] interval: 30s timeout: 5s retries: 3 start_period: 10s deploy: replicas: 3 restart_policy: condition: any delay: 5s update_config: parallelism: 1 delay: 10s order: start-first rollback_config: parallelism: 1 delay: 5s order: stop-first placement: max_replicas_per_node: 1 resources: limits: cpus: "0.25" memory: 128M reservations: cpus: "0.05" memory: 32M networks: - honeydue-network redis: image: redis:7-alpine command: redis-server --appendonly yes --appendfsync everysec --maxmemory 200mb --maxmemory-policy allkeys-lru volumes: - redis_data:/data healthcheck: test: ["CMD", "redis-cli", "ping"] interval: 10s timeout: 5s retries: 5 deploy: replicas: 1 restart_policy: condition: any delay: 5s placement: max_replicas_per_node: 1 resources: limits: cpus: "0.50" memory: 256M reservations: cpus: "0.10" memory: 64M networks: - honeydue-network api: image: ${API_IMAGE} # No `ports:` block — Caddy edge service proxies to api:8000 over the # overlay network. Port 8000 is never publicly exposed. environment: PORT: "8000" DEBUG: "${DEBUG}" ALLOWED_HOSTS: "${ALLOWED_HOSTS}" CORS_ALLOWED_ORIGINS: "${CORS_ALLOWED_ORIGINS}" TIMEZONE: "${TIMEZONE}" BASE_URL: "${BASE_URL}" ADMIN_PANEL_URL: "${ADMIN_PANEL_URL}" DB_HOST: "${DB_HOST}" DB_PORT: "${DB_PORT}" POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_DB: "${POSTGRES_DB}" DB_SSLMODE: "${DB_SSLMODE}" DB_MAX_OPEN_CONNS: "${DB_MAX_OPEN_CONNS}" DB_MAX_IDLE_CONNS: "${DB_MAX_IDLE_CONNS}" DB_MAX_LIFETIME: "${DB_MAX_LIFETIME}" REDIS_URL: "${REDIS_URL}" REDIS_DB: "${REDIS_DB}" EMAIL_HOST: "${EMAIL_HOST}" EMAIL_PORT: "${EMAIL_PORT}" EMAIL_HOST_USER: "${EMAIL_HOST_USER}" DEFAULT_FROM_EMAIL: "${DEFAULT_FROM_EMAIL}" EMAIL_USE_TLS: "${EMAIL_USE_TLS}" APNS_AUTH_KEY_PATH: "/run/secrets/apns_auth_key" APNS_AUTH_KEY_ID: "${APNS_AUTH_KEY_ID}" APNS_TEAM_ID: "${APNS_TEAM_ID}" APNS_TOPIC: "${APNS_TOPIC}" APNS_USE_SANDBOX: "${APNS_USE_SANDBOX}" APNS_PRODUCTION: "${APNS_PRODUCTION}" STORAGE_UPLOAD_DIR: "${STORAGE_UPLOAD_DIR}" STORAGE_BASE_URL: "${STORAGE_BASE_URL}" STORAGE_MAX_FILE_SIZE: "${STORAGE_MAX_FILE_SIZE}" STORAGE_ALLOWED_TYPES: "${STORAGE_ALLOWED_TYPES}" # S3-compatible object storage (Backblaze B2, MinIO). When all B2_* vars # are set, uploads/media are stored in the bucket and the local volume # mount becomes a no-op fallback. Required for multi-replica prod — # without it uploads only exist on one node. B2_ENDPOINT: "${B2_ENDPOINT}" B2_KEY_ID: "${B2_KEY_ID}" B2_APP_KEY: "${B2_APP_KEY}" B2_BUCKET_NAME: "${B2_BUCKET_NAME}" B2_USE_SSL: "${B2_USE_SSL}" B2_REGION: "${B2_REGION}" FEATURE_PUSH_ENABLED: "${FEATURE_PUSH_ENABLED}" FEATURE_EMAIL_ENABLED: "${FEATURE_EMAIL_ENABLED}" FEATURE_WEBHOOKS_ENABLED: "${FEATURE_WEBHOOKS_ENABLED}" FEATURE_ONBOARDING_EMAILS_ENABLED: "${FEATURE_ONBOARDING_EMAILS_ENABLED}" FEATURE_PDF_REPORTS_ENABLED: "${FEATURE_PDF_REPORTS_ENABLED}" FEATURE_WORKER_ENABLED: "${FEATURE_WORKER_ENABLED}" APPLE_CLIENT_ID: "${APPLE_CLIENT_ID}" APPLE_TEAM_ID: "${APPLE_TEAM_ID}" GOOGLE_CLIENT_ID: "${GOOGLE_CLIENT_ID}" GOOGLE_ANDROID_CLIENT_ID: "${GOOGLE_ANDROID_CLIENT_ID}" GOOGLE_IOS_CLIENT_ID: "${GOOGLE_IOS_CLIENT_ID}" APPLE_IAP_KEY_PATH: "${APPLE_IAP_KEY_PATH}" APPLE_IAP_KEY_ID: "${APPLE_IAP_KEY_ID}" APPLE_IAP_ISSUER_ID: "${APPLE_IAP_ISSUER_ID}" APPLE_IAP_BUNDLE_ID: "${APPLE_IAP_BUNDLE_ID}" APPLE_IAP_SANDBOX: "${APPLE_IAP_SANDBOX}" GOOGLE_IAP_SERVICE_ACCOUNT_PATH: "${GOOGLE_IAP_SERVICE_ACCOUNT_PATH}" GOOGLE_IAP_PACKAGE_NAME: "${GOOGLE_IAP_PACKAGE_NAME}" # Seeded on first migration (idempotent — skipped if admin_users row exists) ADMIN_EMAIL: "${ADMIN_EMAIL}" ADMIN_PASSWORD: "${ADMIN_PASSWORD}" stop_grace_period: 60s command: - /bin/sh - -lc - | set -eu export POSTGRES_PASSWORD="$$(cat /run/secrets/postgres_password)" export SECRET_KEY="$$(cat /run/secrets/secret_key)" export EMAIL_HOST_PASSWORD="$$(cat /run/secrets/email_host_password)" export FCM_SERVER_KEY="$$(cat /run/secrets/fcm_server_key)" exec /app/api secrets: - source: postgres_password target: postgres_password - source: secret_key target: secret_key - source: email_host_password target: email_host_password - source: fcm_server_key target: fcm_server_key - source: apns_auth_key target: apns_auth_key volumes: - uploads:/app/uploads healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:8000/api/health/"] interval: 30s timeout: 10s # Single-replica AutoMigrate on a fresh DB takes ~90s; subsequent # replicas are ~2s (idempotent). 180s gives honest headroom for the # first replica to finish, without masking cascade failures. start_period: 180s retries: 3 deploy: replicas: ${API_REPLICAS} # DNS round-robin instead of VIP. VIP's kernel IPVS state can go stale # during replica churn (rolling updates, task restarts), causing # intermittent i/o timeouts from clients on the overlay network (Caddy). # dnsrr resolves to live task IPs directly and bypasses IPVS. endpoint_mode: dnsrr restart_policy: condition: any delay: 5s update_config: parallelism: 1 delay: 10s order: start-first rollback_config: parallelism: 1 delay: 5s order: stop-first resources: limits: cpus: "1.00" memory: 512M reservations: cpus: "0.25" memory: 128M networks: - honeydue-network admin: image: ${ADMIN_IMAGE} # No `ports:` block — reached via Caddy on admin.myhoneydue.com using # Swarm's embedded DNS and default VIP endpoint_mode. environment: PORT: "3000" HOSTNAME: "0.0.0.0" NEXT_PUBLIC_API_URL: "${NEXT_PUBLIC_API_URL}" stop_grace_period: 60s healthcheck: test: ["CMD", "wget", "--no-verbose", "--tries=1", "--spider", "http://127.0.0.1:3000/api/health"] interval: 30s timeout: 10s start_period: 20s retries: 3 deploy: replicas: ${ADMIN_REPLICAS} restart_policy: condition: any delay: 5s update_config: parallelism: 1 delay: 10s order: start-first rollback_config: parallelism: 1 delay: 5s order: stop-first resources: limits: cpus: "0.50" memory: 384M reservations: cpus: "0.10" memory: 128M networks: - honeydue-network worker: image: ${WORKER_IMAGE} environment: DB_HOST: "${DB_HOST}" DB_PORT: "${DB_PORT}" POSTGRES_USER: "${POSTGRES_USER}" POSTGRES_DB: "${POSTGRES_DB}" DB_SSLMODE: "${DB_SSLMODE}" DB_MAX_OPEN_CONNS: "${DB_MAX_OPEN_CONNS}" DB_MAX_IDLE_CONNS: "${DB_MAX_IDLE_CONNS}" DB_MAX_LIFETIME: "${DB_MAX_LIFETIME}" REDIS_URL: "${REDIS_URL}" REDIS_DB: "${REDIS_DB}" EMAIL_HOST: "${EMAIL_HOST}" EMAIL_PORT: "${EMAIL_PORT}" EMAIL_HOST_USER: "${EMAIL_HOST_USER}" DEFAULT_FROM_EMAIL: "${DEFAULT_FROM_EMAIL}" EMAIL_USE_TLS: "${EMAIL_USE_TLS}" APNS_AUTH_KEY_PATH: "/run/secrets/apns_auth_key" APNS_AUTH_KEY_ID: "${APNS_AUTH_KEY_ID}" APNS_TEAM_ID: "${APNS_TEAM_ID}" APNS_TOPIC: "${APNS_TOPIC}" APNS_USE_SANDBOX: "${APNS_USE_SANDBOX}" APNS_PRODUCTION: "${APNS_PRODUCTION}" TASK_REMINDER_HOUR: "${TASK_REMINDER_HOUR}" OVERDUE_REMINDER_HOUR: "${OVERDUE_REMINDER_HOUR}" DAILY_DIGEST_HOUR: "${DAILY_DIGEST_HOUR}" FEATURE_PUSH_ENABLED: "${FEATURE_PUSH_ENABLED}" FEATURE_EMAIL_ENABLED: "${FEATURE_EMAIL_ENABLED}" FEATURE_WEBHOOKS_ENABLED: "${FEATURE_WEBHOOKS_ENABLED}" FEATURE_ONBOARDING_EMAILS_ENABLED: "${FEATURE_ONBOARDING_EMAILS_ENABLED}" FEATURE_PDF_REPORTS_ENABLED: "${FEATURE_PDF_REPORTS_ENABLED}" FEATURE_WORKER_ENABLED: "${FEATURE_WORKER_ENABLED}" stop_grace_period: 60s command: - /bin/sh - -lc - | set -eu export POSTGRES_PASSWORD="$$(cat /run/secrets/postgres_password)" export SECRET_KEY="$$(cat /run/secrets/secret_key)" export EMAIL_HOST_PASSWORD="$$(cat /run/secrets/email_host_password)" export FCM_SERVER_KEY="$$(cat /run/secrets/fcm_server_key)" exec /app/worker secrets: - source: postgres_password target: postgres_password - source: secret_key target: secret_key - source: email_host_password target: email_host_password - source: fcm_server_key target: fcm_server_key - source: apns_auth_key target: apns_auth_key healthcheck: test: ["CMD", "curl", "-f", "http://127.0.0.1:6060/health"] interval: 30s timeout: 10s start_period: 15s retries: 3 deploy: replicas: ${WORKER_REPLICAS} restart_policy: condition: any delay: 5s update_config: parallelism: 1 delay: 10s order: start-first rollback_config: parallelism: 1 delay: 5s order: stop-first resources: limits: cpus: "1.00" memory: 512M reservations: cpus: "0.25" memory: 128M networks: - honeydue-network dozzle: # NOTE: Dozzle exposes the full Docker log stream with no built-in auth. # Bound to manager loopback only — access via SSH tunnel: # ssh -L ${DOZZLE_PORT}:127.0.0.1:${DOZZLE_PORT} # Then browse http://localhost:${DOZZLE_PORT} image: amir20/dozzle:latest # Bind to loopback only on the manager. Swarm's long-form port spec # rejects `host_ip`, so we use the short form — 127.0.0.1::8080. # Access via SSH tunnel: ssh -L ${DOZZLE_PORT}:127.0.0.1:${DOZZLE_PORT} ports: - "127.0.0.1:${DOZZLE_PORT}:8080" environment: DOZZLE_NO_ANALYTICS: "true" volumes: - /var/run/docker.sock:/var/run/docker.sock:ro deploy: replicas: 1 restart_policy: condition: any delay: 5s placement: constraints: - node.role == manager resources: limits: cpus: "0.25" memory: 128M reservations: cpus: "0.05" memory: 32M networks: - honeydue-network volumes: redis_data: uploads: caddy_data: caddy_config: networks: honeydue-network: driver: overlay driver_opts: encrypted: "true" configs: caddyfile: external: true name: ${CADDYFILE_CONFIG} secrets: postgres_password: external: true name: ${POSTGRES_PASSWORD_SECRET} secret_key: external: true name: ${SECRET_KEY_SECRET} email_host_password: external: true name: ${EMAIL_HOST_PASSWORD_SECRET} fcm_server_key: external: true name: ${FCM_SERVER_KEY_SECRET} apns_auth_key: external: true name: ${APNS_AUTH_KEY_SECRET}