# polyrouter self-host stack (#22, spec §13): app + postgres + redis (+ an # optional apprise fan-out service behind the "apprise" profile). Product # compose — dev infrastructure lives in docker-compose.dev.yml. # # ./install.sh # generates .env (secrets) and boots the stack # docker compose up -d # or manually, once .env exists # # The app publishes on LOOPBACK by default: the first sign-up becomes the # admin, so claim the instance before exposing it (set POLYROUTER_HOST=0.0.0.0 # and APP_URL in .env, ideally behind a reverse proxy with TLS). name: polyrouter-selfhost services: app: build: . image: ${POLYROUTER_IMAGE:-polyrouter:latest} restart: unless-stopped ports: - '${POLYROUTER_HOST:-127.0.0.1}:${POLYROUTER_PORT:-3001}:3001' depends_on: postgres: condition: service_healthy redis: condition: service_healthy # In-flight streams drain on SIGTERM (spec §3.2); give them time before KILL. stop_grace_period: 45s environment: NODE_ENV: production MODE: selfhosted # Inside the container the port must bind all interfaces or the published # port is dead; the host-side exposure is controlled by `ports` above. BIND_ADDRESS: 0.0.0.0 PORT: '3001' DATABASE_URL: postgresql://polyrouter:${POSTGRES_PASSWORD}@postgres:5432/polyrouter REDIS_URL: redis://redis:6379 # Follows the published port unless APP_URL overrides (set APP_URL when # exposing behind a real origin/reverse proxy). BETTER_AUTH_URL: ${APP_URL:-http://localhost:${POLYROUTER_PORT:-3001}} BETTER_AUTH_SECRET: ${BETTER_AUTH_SECRET:?set in .env (install.sh generates it)} API_KEY_HMAC_SECRET: ${API_KEY_HMAC_SECRET:?set in .env (install.sh generates it)} PROVIDER_CREDENTIAL_KEY: ${PROVIDER_CREDENTIAL_KEY:?set in .env (install.sh generates it)} NOTIFY_CREDENTIALS_SECRET: ${NOTIFY_CREDENTIALS_SECRET:?set in .env (install.sh generates it)} # Optional pass-through: bare keys stay UNSET in the container when unset # here (several of these reject empty strings at boot). METRICS_ENABLED: OTEL_ENABLED: OTEL_EXPORTER_OTLP_ENDPOINT: OTEL_EXPORTER_OTLP_TRACES_ENDPOINT: OTEL_SERVICE_NAME: APPRISE_API_URL: NOTIFY_ALLOWED_ENDPOINTS: NOTIFY_APPRISE_EGRESS_CONFIRMED: SMTP_HOST: SMTP_PORT: SMTP_USER: SMTP_PASS: SMTP_FROM: SMTP_SECURE: ROUTING_AUTO_LAYERS: ROUTING_STRUCTURAL_HIGH_THRESHOLD: ROUTING_STRUCTURAL_LOW_THRESHOLD: ROUTING_STRUCTURAL_BASELINE_ALPHA: ROUTING_STRUCTURAL_WEIGHTS: ROUTING_CASCADE_QUALITY_THRESHOLD: ROUTING_CASCADE_CHEAP_TIMEOUT_MS: CALIBRATION_SCHED_ENABLED: CALIBRATION_SCHED_CRON: CALIBRATION_WINDOW_DAYS: CALIBRATION_MIN_EDGE_SAMPLES: CALIBRATION_STEP: CALIBRATION_MAX_DRIFT: TRUSTED_PROXY_CIDRS: BUDGET_FAIL_OPEN: BUDGET_SCHED_ENABLED: BUDGET_SCHED_CRON: BUDGET_STALE_MS: BUDGET_REDIS_TIMEOUT_MS: BUDGET_RECONCILE_TIMEOUT_MS: BUDGET_CACHE_TTL_MS: BUDGET_CACHE_MAX: NOTIFY_WEEKLY_ENABLED: NOTIFY_WEEKLY_CRON: NOTIFY_FAILURE_THRESHOLD: NOTIFY_FAILURE_WINDOW_MS: PRICING_REFRESH_URL: PRICING_REFRESH_SCHED_ENABLED: PRICING_REFRESH_SCHED_CRON: PRICING_FETCH_TIMEOUT_MS: PRICING_MAX_BYTES: PROXY_MAX_BODY_BYTES: PROXY_FIRST_EVENT_TIMEOUT_MS: PROXY_EVENT_TIMEOUT_MARGIN_MS: PROXY_IDLE_TIMEOUT_MS: EVENTS_ENABLED: EVENTS_HEARTBEAT_MS: EVENTS_RECONCILE_MS: EVENTS_MAX_STREAMS_PER_OWNER: EVENTS_QUEUE_LIMIT: EVENTS_NUDGE_COALESCE_MS: EVENTS_REVALIDATE_MS: DASHBOARD_ORIGIN: SEED_DATA: GOOGLE_CLIENT_ID: GOOGLE_CLIENT_SECRET: GITHUB_CLIENT_ID: GITHUB_CLIENT_SECRET: DISCORD_CLIENT_ID: DISCORD_CLIENT_SECRET: postgres: image: postgres:16-alpine restart: unless-stopped environment: POSTGRES_USER: polyrouter POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?set in .env (install.sh generates it)} POSTGRES_DB: polyrouter volumes: - polyrouter-pg:/var/lib/postgresql/data healthcheck: test: ['CMD-SHELL', 'pg_isready -U polyrouter -d polyrouter'] interval: 5s timeout: 3s retries: 12 redis: image: redis:7-alpine restart: unless-stopped volumes: - polyrouter-redis:/data healthcheck: test: ['CMD', 'redis-cli', 'ping'] interval: 5s timeout: 3s retries: 12 # Optional URL-based notification fan-out (spec §10.1). Opt-in: # docker compose --profile apprise up -d # then wire it in .env (both lines — the SSRF guard requires the port-bounded # allowlist for a private-range host, by design): # APPRISE_API_URL=http://apprise:8000 # NOTIFY_ALLOWED_ENDPOINTS=apprise,172.28.5.0/24,8000 apprise: image: caronc/apprise:latest profiles: ['apprise'] restart: unless-stopped volumes: - polyrouter-apprise:/config networks: # Pinned subnet so the documented NOTIFY_ALLOWED_ENDPOINTS CIDR is # deterministic. Override POLYROUTER_SUBNET in .env (and keep the allowlist # entry in sync) if it collides with an existing network. default: ipam: config: - subnet: ${POLYROUTER_SUBNET:-172.28.5.0/24} volumes: polyrouter-pg: polyrouter-redis: polyrouter-apprise: