The installer generates the five required secrets into a mode-600 .env. Most other variables are Docker Compose pass-throughs you set in that same file. Each value is validated by a Zod schema at boot — an out-of-range or malformed value fails the boot fast, naming the offending variable and the constraint it broke, so an instance never starts silently misconfigured.
Maintained by Anthony Izzo · Last updated
Five secrets protect sessions, agent keys, and encrypted credentials at rest. The installer generates all five — you should never have to write these by hand. They are generated once and never rotated automatically. Rotating PROVIDER_CREDENTIAL_KEY or NOTIFY_CREDENTIALS_SECRET orphans every stored provider/channel credential (you re-enter them), and rotating POSTGRES_PASSWORD breaks DB auth against the existing volume — the installer refuses to regenerate an existing .env for exactly this reason.
| Variable | Default | Purpose |
|---|---|---|
| BETTER_AUTH_SECRET | auto-gen | Session signing key for the dashboard auth plane (Better Auth). |
| API_KEY_HMAC_SECRET | auto-gen | HMAC key that verifies agent API keys on the hot path; also derives the domain-separated per-tenant HMAC used by Layer 2 learning. |
| PROVIDER_CREDENTIAL_KEY | auto-gen | AES-256-GCM key for provider credentials (plain API keys and OAuth envelopes); also encrypts opt-in body capture at rest. |
| NOTIFY_CREDENTIALS_SECRET | auto-gen | AES-256-GCM key for notification-channel configuration. |
| POSTGRES_PASSWORD | auto-gen | PostgreSQL database password. Feeds the default DATABASE_URL. |
Server binding, public origin, trusted dashboard origin, sign-in providers, and the self-host vs. cloud mode switch. A non-Compose run binds loopback by default — expose it only behind a reverse proxy.
| Variable | Default | Purpose |
|---|---|---|
| MODE | selfhosted | selfhosted (default) or cloud. Gates local-model use, loopback, opt-in body capture, localhost login, and the SSRF loopback exception. |
| NODE_ENV | development | development, production, or test. Compose pins production. On a loopback-bound, non-production self-host the four app secrets fall back to fixed dev values instead of being required — anything network-reachable or production demands the real ones. |
| PORT | 3001 | HTTP server port inside the container. |
| POLYROUTER_HOST | 127.0.0.1 | Host interface the published port binds to — loopback by default. |
| POLYROUTER_PORT | 3001 | Host port the app is published on. |
| POLYROUTER_SUBNET | 172.28.5.0/24 | CIDR for the Compose network — deterministic so NOTIFY_ALLOWED_ENDPOINTS can name the Apprise sidecar by address. Change it on a subnet collision, and re-sync the allowlist CIDR or Apprise delivery stops. |
| APP_URL | unset | Public origin for auth callbacks and cookies. Set it when exposing the instance behind a reverse proxy. Compose interpolates it into BETTER_AUTH_URL; on bare-node or Kubernetes it has no effect. |
| BETTER_AUTH_URL | http://127.0.0.1:3001 | The variable the app actually reads for its auth base URL — set it directly on non-Compose deploys. Must be a valid URL or boot rejects. It is also the origin notifications link back to: a loopback value is normalized to null and every deep link is omitted from the message. |
| DASHBOARD_ORIGIN | http://localhost:3000 | Origin trusted for dashboard sessions — better-auth trustedOrigins and the session-guard Origin check; also the dev-only CORS allow-list (CORS is enabled only when NODE_ENV=development, and Compose pins production). |
| BIND_ADDRESS | 0.0.0.0 (in Compose) | HTTP bind inside the container. The shipped docker-compose.yml already hard-sets 0.0.0.0 — it is not a .env pass-through. Host-side exposure is controlled by POLYROUTER_HOST in the port mapping. 127.0.0.1 is the default only for a non-Compose run. |
| POLYROUTER_IMAGE | polyrouter:latest | Prebuilt multi-arch image override (e.g. ghcr.io/izzoa/polyrouter:latest) to skip the local build. Unset resolves to the locally-built polyrouter:latest; under the semantic overlay it resolves to polyrouter:latest-semantic. |
| TRUSTED_PROXY_CIDRS | unset | Comma-separated CIDRs of reverse proxies allowed to set X-Forwarded-For. Strict: a single malformed entry rejects boot rather than silently trusting every peer. |
| GOOGLE_CLIENT_ID · GOOGLE_CLIENT_SECRET · GITHUB_CLIENT_ID · GITHUB_CLIENT_SECRET · DISCORD_CLIENT_ID · DISCORD_CLIENT_SECRET | unset | Social sign-in credentials. A provider appears on the login page only when both its id and its secret are set; email/password is always enabled. |
| SEED_DATA | false | Set true to seed a dev admin account at boot. Allowed only on a loopback-bound, non-production, self-hosted instance — anywhere else it throws at boot rather than seeding. |
Connection strings for the PostgreSQL 16 and Redis 7 services the stack ships with. The values below are what the shipped docker-compose.yml hard-sets on the app service — like BIND_ADDRESS they are not .env pass-throughs, so pointing at an external datastore means editing the compose file (or your own manifest), not the environment. On a non-Compose run the app's own defaults are loopback: postgresql://polyrouter:polyrouter@localhost:5432/polyrouter and redis://localhost:6379.
| Variable | Default | Purpose |
|---|---|---|
| DATABASE_URL | postgresql://polyrouter:${POSTGRES_PASSWORD}@postgres:5432/polyrouter | PostgreSQL connection string. Migrations run on boot. |
| REDIS_URL | redis://redis:6379 | Redis connection string. Backs circuit breakers, budget counters, rate limits, and Layer 2 learned state. |
Layer toggles, the structural, workload and cascade thresholds, and the global proxy timeouts. Out-of-range values reject boot rather than being silently clamped, and both structural thresholds are held to four decimal places for the calibration rails.
| Variable | Default | Purpose |
|---|---|---|
| ROUTING_AUTO_LAYERS | structural | Comma-separated layer tokens — structural, cascade, semantic. cascade and semantic both imply structural; unknown tokens reject boot naming the offender. |
| ROUTING_STRUCTURAL_HIGH_THRESHOLD | 0.6 | Layer 1 high-band threshold, range 0–1. |
| ROUTING_STRUCTURAL_LOW_THRESHOLD | 0.25 | Layer 1 low-band threshold. Must be lower than HIGH or the bands collapse and boot rejects. |
| ROUTING_STRUCTURAL_BASELINE_ALPHA | 0.2 | Per-agent baseline EMA weight, interval (0, 1]. The baseline is subtracted so a big harness preamble can't force everything into the top tier. |
| ROUTING_STRUCTURAL_WEIGHTS | built-ins | JSON override for the Layer 1 classifier weights and the reasoning adjustment. |
| ROUTING_WORKLOAD_THRESHOLDS | built-ins | JSON override for the structural workload classifier — {"codeShare":0.3,"codeMinChars":200} by default. An auto request records workload code when fenced code is at least that share of the scanned window and at least that many chars; vision (an image block) and structured (a declared output format) are binary and untunable. codeShare must be finite in (0, 1] and codeMinChars a non-negative integer — malformed JSON, an unknown key, or an out-of-range value rejects boot naming the variable. Not on the shipped compose allow-list: add it to the app service's environment: block to reach the container. |
| ROUTING_CASCADE_QUALITY_THRESHOLD | 0.5 | Cascade escalation threshold — accept the cheap tier at or above it, escalate to the strong tier below. |
| ROUTING_CASCADE_CHEAP_TIMEOUT_MS | 30000 | Cascade cheap-tier timeout (ms). |
| PROXY_FIRST_EVENT_TIMEOUT_MS | 30000 | Global time-to-first-token deadline. Raise for slow local models. |
| PROXY_IDLE_TIMEOUT_MS | 30000 | Global idle timeout. Raise alongside the first-token deadline. |
| PROXY_EVENT_TIMEOUT_MARGIN_MS | 500 | Added to PROXY_FIRST_EVENT_TIMEOUT_MS to derive the core per-event stream deadline (30 500 ms by default), so the adapter's typed unavailable timeout wins a pre-headers race. |
| PROXY_MAX_BODY_BYTES | 10485760 | Maximum /v1 request body size in bytes (10 MiB default). |
Layer 2 refines only the structurally-ambiguous slice by embedding request text with a local ONNX model — and that same embedding is what lights up the semantic workload classes research and writing, one embed serving both verdicts. It stays absent until you point SEMANTIC_MODEL_PATH at a model bundle — an unset path means zero import and zero overhead.
| Variable | Default | Purpose |
|---|---|---|
| SEMANTIC_MODEL_PATH | unset | Path to a local model bundle. Unset = the module is absent; set and valid = Layer 2 capability and the semantic workload source (research / writing, anchors bundled); set and broken = boot fails fast naming the file and reason. |
| SEMANTIC_TIMEOUT_MS | 50 | Per-embed hard timeout (ms). Out-of-range rejects boot. |
| SEMANTIC_MAX_INPUT_CHARS | 2000 | Input cap before tokenization (chars). |
| SEMANTIC_CONCURRENCY | 2 | Concurrent-inference cap. Saturation skips the layer for that request. |
| SEMANTIC_HIGH_THRESHOLD | 0.15 | Layer 2 high-band threshold — a score at or above it routes high. |
| SEMANTIC_LOW_THRESHOLD | 0.15 | Layer 2 low-band threshold — a score at or below minus this value routes low. Held to four decimal places. |
| SEMANTIC_WORKLOAD_MARGIN | 0.05 | Workload discriminating rail: a structural-none request records research or writing only when the winning anchor leads every other class by at least this much. |
| SEMANTIC_WORKLOAD_MIN_SIM | 0.2 | Workload near-orthogonality guard: the winning cosine must also clear this floor, else the class stays none. Both rails are held to four decimal places and are digested into the recorded workload revision, so retuning one never mixes two populations of samples. |
The per-tenant learning loop tracks a tenant's own outcome-labeled traffic to nudge learned centroids. It is off by default for privacy — the per-tenant semanticLearning preference defaults off. These knobs bound how far learned state may drift from the bundled anchors and how the daily sweep is gated. One workload caveat: a cascade whose selected cheap leg came from a class-scoped band teaches nothing — the evidence binds to the generic cheap chain, and a sample from a different chain would mislabel the centroid.
| Variable | Default | Purpose |
|---|---|---|
| SEMANTIC_LEARNING_ALPHA | 0.2 | EMA fold coefficient, interval (0, 0.5]. |
| SEMANTIC_LEARNING_MAX_DRIFT | 0.35 | Maximum cosine-distance clamp from the bundled centroid (spherical SLERP). |
| SEMANTIC_LEARNING_MIN_SAMPLES | 50 | Per-label floor for a sweep rotation. Must be at least SEMANTIC_LEARNING_MIN_COHORT or boot rejects. |
| SEMANTIC_LEARNING_MIN_COHORT | 8 | Minimum accumulated embeddings before a cohort may flush to Redis — never a single raw vector. |
| SEMANTIC_LEARNING_MAX_COHORTS | 4096 | Bounded per-process cohort map. |
| SEMANTIC_LEARNING_COOLDOWN_H | 24 | Cooldown between applies (hours). Must stay under STATE_TTL_D × 24. |
| SEMANTIC_LEARNING_STATE_TTL_D | 30 | Learned active-state TTL (days). |
| SEMANTIC_LEARNING_SCHED_ENABLED | true | Enable the per-tenant learning sweep worker (only acts on tenants that opted in). |
| SEMANTIC_LEARNING_SCHED_CRON | 0 3 * * * | Learning sweep cron. |
The daily per-tenant sweep that narrows the structural ambiguous band from your own outcomes. It is conservative, rail-bounded, audited, and one-click revertible — a poisoned or stale calibrated pair can never fail routing; it simply reads as the instance defaults.
| Variable | Default | Purpose |
|---|---|---|
| CALIBRATION_SCHED_ENABLED | true | Enable the calibration worker. It sweeps only tenants who turned calibration on themselves — the per-tenant preference defaults to off, so on a fresh instance this alone calibrates nobody. |
| CALIBRATION_SCHED_CRON | 0 4 * * * | Calibration sweep cron. |
| CALIBRATION_WINDOW_DAYS | 14 | Evidence window (days). |
| CALIBRATION_MIN_EDGE_SAMPLES | 50 | Minimum fresh edge-zone samples (hard floor 50). |
| CALIBRATION_STEP | 0.02 | Bounded per-run threshold step. |
| CALIBRATION_MAX_DRIFT | 0.1 | Maximum total drift from the instance thresholds. |
Budget enforcement fault behavior and the reconciler schedule. Enforcement itself uses atomic Redis counters that stay correct across instances.
| Variable | Default | Purpose |
|---|---|---|
| BUDGET_FAIL_OPEN | true | On a Redis or enforcement fault, blocking budgets admit the request. Set false for a hard cap that returns 503. |
| BUDGET_SCHED_ENABLED | true | Enable the budget reconciler worker. |
| BUDGET_SCHED_CRON | * * * * * | Budget reconciler cron (every minute). Boot fails if its fire interval exceeds half of BUDGET_STALE_MS. |
| BUDGET_STALE_MS | 180000 | How stale the reconcile heartbeat may get before block enforcement stops trusting the counters and routes through BUDGET_FAIL_OPEN. It must clear at least twice the BUDGET_SCHED_CRON fire interval or boot fails naming the gap. |
| BUDGET_REDIS_TIMEOUT_MS · BUDGET_RECONCILE_TIMEOUT_MS · BUDGET_CACHE_TTL_MS · BUDGET_CACHE_MAX | 50 · 2000 · 10000 · 5000 | Hot-path Redis read bound, the reconciler's more generous write bound, and the in-process per-owner budget cache (TTL in ms, capped owners) that keeps the block check DB-free. |
The daily refresh of the bundled pricing catalog from the public LiteLLM source. New prices apply to new requests only — recorded costs are immutable and never rewritten.
| Variable | Default | Purpose |
|---|---|---|
| PRICING_REFRESH_URL | LiteLLM catalog | Source for pricing refreshes — the public LiteLLM catalog. |
| PRICING_REFRESH_SCHED_ENABLED | true | Daily auto-refresh of the pricing catalog. Set exactly false to opt out — it is an exact-string compare, so 0, no and off leave it on. The worker additionally requires MODE=selfhosted. |
| PRICING_REFRESH_SCHED_CRON | 30 4 * * * | Pricing refresh cron. Invalid syntax fails boot rather than silently reporting as enabled. |
| PRICING_FETCH_TIMEOUT_MS · PRICING_MAX_BYTES | 15000 · 8000000 | Timeout and body-size cap on the SSRF-guarded catalog pull. |
SMTP and/or Apprise channels for budget alerts and blocks, provider-down, and failure spikes, plus the weekly summary. Everything is queued off the request path — a failing channel never blocks a request or budget enforcement.
| Variable | Default | Purpose |
|---|---|---|
| SMTP_HOST · SMTP_PORT · SMTP_USER · SMTP_PASS · SMTP_FROM · SMTP_SECURE | unset · 587 · unset · unset · unset · starttls | Server SMTP transport — host, port, credentials, from-address, and TLS mode. Active only when both SMTP_HOST and SMTP_FROM are set. SMTP_SECURE accepts exactly none | starttls | tls. |
| APPRISE_API_URL · NOTIFY_ALLOWED_ENDPOINTS | unset | Optional Apprise URL fan-out. The SSRF guard requires the port-bounded allowlist entry for a private-range host. |
| NOTIFY_APPRISE_EGRESS_CONFIRMED | false | Cloud-mode acknowledgement required before Apprise delivery runs. |
| NOTIFY_WEEKLY_ENABLED · NOTIFY_WEEKLY_CRON | false · 0 8 * * 1 | Weekly summary producer toggle and cron. Off by default — set NOTIFY_WEEKLY_ENABLED=true (exact string). Default schedule is Mondays 08:00 UTC. |
| NOTIFY_FAILURE_THRESHOLD · NOTIFY_FAILURE_WINDOW_MS | 20 · 900000 | Failure-spike producer threshold and window — 20 failures in a 15-minute window. |
The server-sent event stream at /api/events that pushes live dashboard updates instead of polling. Every knob is defaulted, and the cross-field rules fail boot fast rather than silently loosening a bound.
| Variable | Default | Purpose |
|---|---|---|
| EVENTS_ENABLED | true | Dashboard event stream toggle. Set false to turn it off. |
| EVENTS_HEARTBEAT_MS | 25000 | Keep-alive interval — it must stay under the ~60 s idle-reap window common to proxies and CDNs, so boot fails at 60000 or above. Lower it for a stricter intermediary. |
| EVENTS_RECONCILE_MS | 30000 | Low-rate verification read that confirms the stream is current. It verifies the stream rather than replacing it, so boot fails below 2500. |
| EVENTS_REVALIDATE_MS | derived | Server-side authorization revalidation bound — the revocation-detection window. Unset it derives from the heartbeat (at most 15 000 ms and never looser than it), so lowering the heartbeat can't fail boot on an unrelated knob; an explicit value above the heartbeat rejects boot. |
| EVENTS_MAX_STREAMS_PER_OWNER | 6 | Concurrent streams one account may hold (open tabs), range 1–64. |
| EVENTS_QUEUE_LIMIT | 256 | Bounded per-stream outbound queue, range 8–10 000. |
| EVENTS_NUDGE_COALESCE_MS | 1000 | Window over which analytics nudges are coalesced into one push. Minimum 1000. |
Prometheus metrics on by default, plus opt-in OpenTelemetry tracing. Health is always at /api/health.
| Variable | Default | Purpose |
|---|---|---|
| OTEL_ENABLED | false | OpenTelemetry tracing toggle. |
| OTEL_EXPORTER_OTLP_ENDPOINT | SDK default | OTLP/HTTP collector endpoint (batched export). A malformed URL fails boot; an absent one falls back to the exporter's own default. |
| OTEL_EXPORTER_OTLP_TRACES_ENDPOINT | unset | Per-signal traces override. The exporter reads it directly and it takes precedence over the generic endpoint. |
| OTEL_SERVICE_NAME | polyrouter | Service name attached to exported spans. |
| METRICS_ENABLED | true | Prometheus /metrics endpoint toggle. |