x-app-environment: &app-environment DEPLOYMENT_ENV: ${DEPLOYMENT_ENV:?Set DEPLOYMENT_ENV in .env} APP_ROLE: web DATABASE_URL: ${DATABASE_URL:?Set DATABASE_URL in .env} DATABASE_SOCKET_DIR: ${DATABASE_SOCKET_DIR:-} SECRET_KEY_BASE: ${SECRET_KEY_BASE:?Set SECRET_KEY_BASE in .env} HANDOVER_SECRET: ${HANDOVER_SECRET:?Set HANDOVER_SECRET in .env} RELEASE_COOKIE: ${RELEASE_COOKIE:?Set RELEASE_COOKIE in .env} METRICS_TOKEN: ${METRICS_TOKEN:?Set an independent METRICS_TOKEN in .env} # Keep BEAM from deriving a massive port table from a host/container nofile # limit. OTP's normal default is 65,536; deployments can override it after # measuring their concurrent file/socket requirements. ERL_ZFLAGS: "+Q ${ERLANG_PORT_LIMIT:-65536}" CLUSTER_INTERFACE: ${CLUSTER_INTERFACE:-eth0} DNS_CLUSTER_QUERY: ${DNS_CLUSTER_QUERY:-cluster-web} PHX_HOST: ${PHX_HOST:?Set PHX_HOST in .env} PHX_SCHEME: ${PHX_SCHEME:?Set PHX_SCHEME in .env} PHX_URL_PORT: ${PHX_URL_PORT:?Set PHX_URL_PORT in .env} PHX_CHECK_ORIGINS: ${PHX_CHECK_ORIGINS:-} PORT: "4000" EMAIL_DELIVERY_PROVIDER: ${EMAIL_DELIVERY_PROVIDER:-smtp} SMTP_RELAY: ${SMTP_RELAY:-mailpit} SMTP_PORT: ${SMTP_PORT:-1025} SMTP_USERNAME: ${SMTP_USERNAME:-} SMTP_PASSWORD: ${SMTP_PASSWORD:-} SMTP_AUTH: ${SMTP_AUTH:-never} SMTP_TLS: ${SMTP_TLS:-never} SMTP_SSL: ${SMTP_SSL:-false} EMAIL_FROM_NAME: ${EMAIL_FROM_NAME:?Set EMAIL_FROM_NAME in .env} EMAIL_FROM_ADDRESS: ${EMAIL_FROM_ADDRESS:?Set EMAIL_FROM_ADDRESS in .env} SUPPORT_INBOX_ADDRESS: ${SUPPORT_INBOX_ADDRESS:-} SUPPORT_INBOUND_RECIPIENT: ${SUPPORT_INBOUND_RECIPIENT:-} SUPPORT_INBOUND_WEBHOOK_TOKEN: ${SUPPORT_INBOUND_WEBHOOK_TOKEN:-} CODEX_SESSION_ID: ${CODEX_SESSION_ID:-not-configured} # Empty/unset uses the compiled pilot policy. Set exactly {} only for an # isolated benchmark or test environment that must disable every counter. RATE_LIMIT_POLICIES_JSON: ${RATE_LIMIT_POLICIES_JSON:-} PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS: ${PUBLIC_CONTACT_VERIFICATION_MAX_AGE_SECONDS:-86400} PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS: ${PUBLIC_CASE_ACCESS_MAX_AGE_SECONDS:-31536000} MAP_TILE_URL: ${MAP_TILE_URL:-https://tile.openstreetmap.org/{z}/{x}/{y}.png} GITHUB_OAUTH_CLIENT_ID: ${GITHUB_OAUTH_CLIENT_ID:-} GITHUB_OAUTH_CLIENT_SECRET: ${GITHUB_OAUTH_CLIENT_SECRET:-} GITHUB_OAUTH_BASE_URL: ${GITHUB_OAUTH_BASE_URL:-} GITHUB_OAUTH_AUTHORIZE_URL: ${GITHUB_OAUTH_AUTHORIZE_URL:-} GITHUB_OAUTH_TOKEN_URL: ${GITHUB_OAUTH_TOKEN_URL:-} GITHUB_OAUTH_USER_URL: ${GITHUB_OAUTH_USER_URL:-} GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-} GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GITHUB_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-} GOOGLE_OAUTH_CLIENT_ID: ${GOOGLE_OAUTH_CLIENT_ID:-} GOOGLE_OAUTH_CLIENT_SECRET: ${GOOGLE_OAUTH_CLIENT_SECRET:-} GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS: ${GOOGLE_OAUTH_AUTHORIZED_PARTY_IDS:-} GOOGLE_OAUTH_BASE_URL: ${GOOGLE_OAUTH_BASE_URL:-} GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_CONNECT_TIMEOUT_MS:-} GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS: ${GOOGLE_OAUTH_HTTP_RECEIVE_TIMEOUT_MS:-} PUSH_HTTP_ENDPOINT: ${PUSH_HTTP_ENDPOINT:-} PUSH_HTTP_BEARER_TOKEN: ${PUSH_HTTP_BEARER_TOKEN:-} PUSH_HTTP_MAX_ATTEMPTS: ${PUSH_HTTP_MAX_ATTEMPTS:-} PUSH_HTTP_RECEIVE_TIMEOUT_MS: ${PUSH_HTTP_RECEIVE_TIMEOUT_MS:-} PUSH_HTTP_CONNECT_TIMEOUT_MS: ${PUSH_HTTP_CONNECT_TIMEOUT_MS:-} PUSH_HTTP_RETRY_DELAY_MS: ${PUSH_HTTP_RETRY_DELAY_MS:-} WEB_PUSH_VAPID_PUBLIC_KEY: ${WEB_PUSH_VAPID_PUBLIC_KEY:-} WEB_PUSH_VAPID_PRIVATE_KEY: ${WEB_PUSH_VAPID_PRIVATE_KEY:-} WEB_PUSH_VAPID_SUBJECT: ${WEB_PUSH_VAPID_SUBJECT:-} FCM_PROJECT_ID: ${FCM_PROJECT_ID:-} FCM_SERVICE_ACCOUNT_FILE: ${FCM_SERVICE_ACCOUNT_FILE:-} FCM_SERVICE_ACCOUNT_JSON_BASE64: ${FCM_SERVICE_ACCOUNT_JSON_BASE64:-} ANDROID_APP_LINKS_PACKAGE_NAME: ${ANDROID_APP_LINKS_PACKAGE_NAME:-} ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS: ${ANDROID_APP_LINKS_SHA256_CERT_FINGERPRINTS:-} OBAN_MAINTENANCE_CONCURRENCY: ${OBAN_MAINTENANCE_CONCURRENCY:-2} OBAN_PUSH_CONCURRENCY: ${OBAN_PUSH_CONCURRENCY:-1} OBAN_MAIL_CONCURRENCY: ${OBAN_MAIL_CONCURRENCY:-1} services: docker-api-proxy: image: ${SOCKET_PROXY_IMAGE:?Set SOCKET_PROXY_IMAGE in .env} build: context: . dockerfile: Dockerfile.socket-proxy environment: CONTAINERS: "1" EVENTS: "1" INFO: "1" NETWORKS: "1" PING: "1" POST: "0" VERSION: "1" LOG_LEVEL: warning volumes: - /var/run/docker.sock:/var/run/docker.sock:ro group_add: - ${DOCKER_SOCKET_GID:?Set DOCKER_SOCKET_GID to the numeric group of /var/run/docker.sock} networks: [docker-api] read_only: true tmpfs: - /run:uid=99,gid=99,mode=0755 - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped proxy: image: who-need-help:traefik-v3.7.10-grpc1.82.1-xmod0.40.0 build: context: . dockerfile: Dockerfile.traefik command: - --api.dashboard=${TRAEFIK_API_INSECURE:-false} - --api.insecure=${TRAEFIK_API_INSECURE:-false} - --providers.docker=true - --providers.docker.endpoint=tcp://docker-api-proxy:2375 - --providers.docker.exposedbydefault=false - --providers.docker.constraints=Label(`com.docker.compose.project`,`${TRAEFIK_PROJECT_CONSTRAINT:-who_need_help}`) - --entrypoints.web.address=:80 - --entrypoints.websecure.address=:443 - --entrypoints.web.forwardedheaders.trustedips=${TRAEFIK_TRUSTED_IPS:-127.0.0.1/32} ports: - "${HTTP_BIND_ADDRESS:-0.0.0.0}:${HTTP_PORT:-4010}:80" depends_on: - docker-api-proxy networks: [docker-api, edge, ingress] read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped db: image: ${POSTGIS_IMAGE:?Set POSTGIS_IMAGE in .env} build: context: . dockerfile: Dockerfile.postgis environment: # Profiles are resolved after interpolation. These disabled-definition # fallbacks let external mode render without inventing live credentials; # scripts/compose.sh requires all three values before container mode. POSTGRES_DB: ${POSTGRES_DB:-disabled_external_database} POSTGRES_USER: ${POSTGRES_USER:-disabled_external_database} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-disabled-external-database} healthcheck: # The image starts a temporary Unix-socket-only PostgreSQL while it loads # PostGIS into a fresh database, then stops it and starts the final server. # Checking TCP prevents dependants from starting during that bootstrap window. test: ["CMD-SHELL", "pg_isready -h 127.0.0.1 -U $$POSTGRES_USER -d $$POSTGRES_DB"] interval: 3s timeout: 3s retries: 20 volumes: - postgres_data:/var/lib/postgresql networks: internal: interface_name: eth0 security_opt: - no-new-privileges:true restart: unless-stopped mailpit: image: who-need-help:mailpit-v1.30.7-go1.26.7-xmod0.40.0 build: context: . dockerfile: Dockerfile.mailpit user: "65534:65534" ports: - "${MAILPIT_BIND_ADDRESS:-127.0.0.1}:${MAILPIT_PORT:-8027}:8025" networks: [edge, internal] read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped migrate: image: ${APP_IMAGE:?Set APP_IMAGE in .env} build: context: . environment: <<: *app-environment APP_ROLE: migrate POOL_SIZE: ${MIGRATE_POOL_SIZE:-${POOL_SIZE:?Set MIGRATE_POOL_SIZE in .env}} command: ["/app/bin/migrate"] extra_hosts: - "host.docker.internal:host-gateway" depends_on: db: condition: service_healthy networks: internal: interface_name: eth0 egress: interface_name: eth1 gw_priority: 1 read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: "no" app: profiles: [compact] image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: combined PHX_SERVER: "true" DNS_CLUSTER_QUERY: ignore POOL_SIZE: ${COMBINED_POOL_SIZE:-${WEB_POOL_SIZE:-4}} depends_on: migrate: condition: service_completed_successfully extra_hosts: - "host.docker.internal:host-gateway" ports: - "${HTTP_BIND_ADDRESS:-127.0.0.1}:${HTTP_PORT:-4010}:4000" healthcheck: test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] interval: 10s timeout: 3s retries: 10 networks: internal: interface_name: eth0 egress: interface_name: eth1 gw_priority: 1 read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped web: image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: web PHX_SERVER: "true" POOL_SIZE: ${WEB_POOL_SIZE:-${POOL_SIZE:?Set WEB_POOL_SIZE in .env}} depends_on: migrate: condition: service_completed_successfully extra_hosts: - "host.docker.internal:host-gateway" labels: - traefik.enable=true - traefik.docker.network=${TRAEFIK_DOCKER_NETWORK:-who_need_help_ingress} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.rule=${TRAEFIK_ROUTER_RULE:-PathPrefix(`/`)} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.entrypoints=web - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.service=${TRAEFIK_APP_NAME:-who-need-help} - traefik.http.routers.${TRAEFIK_APP_NAME:-who-need-help}.middlewares=${TRAEFIK_APP_NAME:-who-need-help}-retry - traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.attempts=${TRAEFIK_RETRY_ATTEMPTS:-3} - traefik.http.middlewares.${TRAEFIK_APP_NAME:-who-need-help}-retry.retry.status=500-599 - traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.server.port=4000 - traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.path=/healthz/ready - traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.interval=10s - traefik.http.services.${TRAEFIK_APP_NAME:-who-need-help}.loadbalancer.healthcheck.timeout=3s healthcheck: test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] interval: 10s timeout: 3s retries: 10 deploy: replicas: ${WEB_REPLICAS:-2} networks: internal: interface_name: eth0 aliases: [cluster-web] ingress: interface_name: eth1 egress: interface_name: eth2 gw_priority: 1 read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped worker: image: ${APP_IMAGE:?Set APP_IMAGE in .env} environment: <<: *app-environment APP_ROLE: worker POOL_SIZE: ${WORKER_POOL_SIZE:-${POOL_SIZE:?Set WORKER_POOL_SIZE in .env}} command: ["/app/bin/who_need_help", "start"] depends_on: migrate: condition: service_completed_successfully extra_hosts: - "host.docker.internal:host-gateway" healthcheck: test: ["CMD", "curl", "--fail", "--silent", "http://localhost:4000/healthz/ready"] interval: 10s timeout: 3s retries: 10 deploy: replicas: ${WORKER_REPLICAS:-2} networks: internal: interface_name: eth0 egress: interface_name: eth1 gw_priority: 1 read_only: true tmpfs: - /tmp cap_drop: [ALL] security_opt: - no-new-privileges:true restart: unless-stopped networks: docker-api: internal: true edge: ingress: internal: true internal: internal: true egress: volumes: postgres_data: