How it works Features Docs Compare Blog GitHub
Self-host it Sign up
Documentation

Configuration reference.

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

Tip Only the five secrets are required, and the installer writes them for you. Every other variable ships a production-ready default — set one only to change behavior. Cross-field rules are enforced too: LOW must be lower than HIGH, unknown ROUTING_AUTO_LAYERS tokens reject boot, and a broken semantic bundle refuses to start rather than run degraded.

Required secrets

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.

VariableDefaultPurpose
BETTER_AUTH_SECRETauto-genSession signing key for the dashboard auth plane (Better Auth).
API_KEY_HMAC_SECRETauto-genHMAC 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_KEYauto-genAES-256-GCM key for provider credentials (plain API keys and OAuth envelopes); also encrypts opt-in body capture at rest.
NOTIFY_CREDENTIALS_SECRETauto-genAES-256-GCM key for notification-channel configuration.
POSTGRES_PASSWORDauto-genPostgreSQL database password. Feeds the default DATABASE_URL.

App & mode

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.

VariableDefaultPurpose
MODEselfhostedselfhosted (default) or cloud. Gates local-model use, loopback, opt-in body capture, localhost login, and the SSRF loopback exception.
NODE_ENVdevelopmentdevelopment, 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.
PORT3001HTTP server port inside the container.
POLYROUTER_HOST127.0.0.1Host interface the published port binds to — loopback by default.
POLYROUTER_PORT3001Host port the app is published on.
POLYROUTER_SUBNET172.28.5.0/24CIDR 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_URLunsetPublic 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_URLhttp://127.0.0.1:3001The 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_ORIGINhttp://localhost:3000Origin 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_ADDRESS0.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_IMAGEpolyrouter:latestPrebuilt 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_CIDRSunsetComma-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_SECRETunsetSocial 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_DATAfalseSet 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.

Database & cache

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.

VariableDefaultPurpose
DATABASE_URLpostgresql://polyrouter:${POSTGRES_PASSWORD}@postgres:5432/polyrouterPostgreSQL connection string. Migrations run on boot.
REDIS_URLredis://redis:6379Redis connection string. Backs circuit breakers, budget counters, rate limits, and Layer 2 learned state.

Routing

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.

VariableDefaultPurpose
ROUTING_AUTO_LAYERSstructuralComma-separated layer tokens — structural, cascade, semantic. cascade and semantic both imply structural; unknown tokens reject boot naming the offender.
ROUTING_STRUCTURAL_HIGH_THRESHOLD0.6Layer 1 high-band threshold, range 0–1.
ROUTING_STRUCTURAL_LOW_THRESHOLD0.25Layer 1 low-band threshold. Must be lower than HIGH or the bands collapse and boot rejects.
ROUTING_STRUCTURAL_BASELINE_ALPHA0.2Per-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_WEIGHTSbuilt-insJSON override for the Layer 1 classifier weights and the reasoning adjustment.
ROUTING_WORKLOAD_THRESHOLDSbuilt-insJSON 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_THRESHOLD0.5Cascade escalation threshold — accept the cheap tier at or above it, escalate to the strong tier below.
ROUTING_CASCADE_CHEAP_TIMEOUT_MS30000Cascade cheap-tier timeout (ms).
PROXY_FIRST_EVENT_TIMEOUT_MS30000Global time-to-first-token deadline. Raise for slow local models.
PROXY_IDLE_TIMEOUT_MS30000Global idle timeout. Raise alongside the first-token deadline.
PROXY_EVENT_TIMEOUT_MARGIN_MS500Added 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_BYTES10485760Maximum /v1 request body size in bytes (10 MiB default).
Note Per-provider upstream timeouts (first_byte_timeout_ms / idle_timeout_ms, range 1 s–1 h) are set on the provider row in the dashboard, not in .env. A NULL there inherits these global proxy defaults.
Workload Beside the complexity score, every auto request Layer 1 evaluates also records a workload class — code, vision and structured from the structural signals, research and writing only with the semantic module loaded, otherwise none. Classification alone routes nothing: you opt in per class on the dashboard's Routing page, either with a Workload target (an auto_workload rule, claimed ahead of the bands and recorded as decision_layer = workload) or by scoping an auto_high / auto_low band to a class, whose reason ends with scope=<class>. There is no workload token for ROUTING_AUTO_LAYERS and no request header to set — it is a second verdict from the same layers. The only env knob is ROUTING_WORKLOAD_THRESHOLDS above, plus the semantic rails under Layer 2.

Layer 2 — semantic optional

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.

VariableDefaultPurpose
SEMANTIC_MODEL_PATHunsetPath 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_MS50Per-embed hard timeout (ms). Out-of-range rejects boot.
SEMANTIC_MAX_INPUT_CHARS2000Input cap before tokenization (chars).
SEMANTIC_CONCURRENCY2Concurrent-inference cap. Saturation skips the layer for that request.
SEMANTIC_HIGH_THRESHOLD0.15Layer 2 high-band threshold — a score at or above it routes high.
SEMANTIC_LOW_THRESHOLD0.15Layer 2 low-band threshold — a score at or below minus this value routes low. Held to four decimal places.
SEMANTIC_WORKLOAD_MARGIN0.05Workload 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_SIM0.2Workload 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.
Note Enabling Layer 2 needs the model runtime present: run the batteries-included -semantic image (ONNX runtime + reference MiniLM model pre-baked, SEMANTIC_MODEL_PATH preset) or install the exact-pinned onnxruntime-node optional peer — then add semantic to ROUTING_AUTO_LAYERS. That instance-level switch is the opt-in: once it is on, every tenant is routed through Layer 2 unless they turn it off under Routing (the per-tenant preference defaults to on). The baseline image ships no runtime and no model.
Compose Layer the override file to get the semantic knobs: docker compose -f docker-compose.yml -f docker-compose.semantic.yml up -d. The base file passes ROUTING_AUTO_LAYERS through from .env but no SEMANTIC_* variable at all, so the -semantic image on the base file alone sits on the app default structural unless you set the layer list yourself — and even then the semantic thresholds stay unreachable. The override file supplies the whole family, points SEMANTIC_MODEL_PATH at the baked model, and defaults the layer list to structural,semantic,cascade.

Layer 2 — learning default off

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.

VariableDefaultPurpose
SEMANTIC_LEARNING_ALPHA0.2EMA fold coefficient, interval (0, 0.5].
SEMANTIC_LEARNING_MAX_DRIFT0.35Maximum cosine-distance clamp from the bundled centroid (spherical SLERP).
SEMANTIC_LEARNING_MIN_SAMPLES50Per-label floor for a sweep rotation. Must be at least SEMANTIC_LEARNING_MIN_COHORT or boot rejects.
SEMANTIC_LEARNING_MIN_COHORT8Minimum accumulated embeddings before a cohort may flush to Redis — never a single raw vector.
SEMANTIC_LEARNING_MAX_COHORTS4096Bounded per-process cohort map.
SEMANTIC_LEARNING_COOLDOWN_H24Cooldown between applies (hours). Must stay under STATE_TTL_D × 24.
SEMANTIC_LEARNING_STATE_TTL_D30Learned active-state TTL (days).
SEMANTIC_LEARNING_SCHED_ENABLEDtrueEnable the per-tenant learning sweep worker (only acts on tenants that opted in).
SEMANTIC_LEARNING_SCHED_CRON0 3 * * *Learning sweep cron.

Calibration

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.

VariableDefaultPurpose
CALIBRATION_SCHED_ENABLEDtrueEnable 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_CRON0 4 * * *Calibration sweep cron.
CALIBRATION_WINDOW_DAYS14Evidence window (days).
CALIBRATION_MIN_EDGE_SAMPLES50Minimum fresh edge-zone samples (hard floor 50).
CALIBRATION_STEP0.02Bounded per-run threshold step.
CALIBRATION_MAX_DRIFT0.1Maximum total drift from the instance thresholds.
Compose These reach the container only if your docker-compose.yml declares them in the app service's environment: block — it is an explicit allow-list, not a blanket pass-through. The shipped compose file lists them, so a current stack picks them up from .env; a compose file you wrote yourself, or one carried over from an older release, needs the keys added — or an env_file: as the prebuilt-image compose in the README does — otherwise setting them in .env silently leaves calibration on its defaults.

Budgets

Budget enforcement fault behavior and the reconciler schedule. Enforcement itself uses atomic Redis counters that stay correct across instances.

VariableDefaultPurpose
BUDGET_FAIL_OPENtrueOn a Redis or enforcement fault, blocking budgets admit the request. Set false for a hard cap that returns 503.
BUDGET_SCHED_ENABLEDtrueEnable 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_MS180000How 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_MAX50 · 2000 · 10000 · 5000Hot-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.

Pricing

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.

VariableDefaultPurpose
PRICING_REFRESH_URLLiteLLM catalogSource for pricing refreshes — the public LiteLLM catalog.
PRICING_REFRESH_SCHED_ENABLEDtrueDaily 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_CRON30 4 * * *Pricing refresh cron. Invalid syntax fails boot rather than silently reporting as enabled.
PRICING_FETCH_TIMEOUT_MS · PRICING_MAX_BYTES15000 · 8000000Timeout and body-size cap on the SSRF-guarded catalog pull.

Notifications

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.

VariableDefaultPurpose
SMTP_HOST · SMTP_PORT · SMTP_USER · SMTP_PASS · SMTP_FROM · SMTP_SECUREunset · 587 · unset · unset · unset · starttlsServer 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_ENDPOINTSunsetOptional Apprise URL fan-out. The SSRF guard requires the port-bounded allowlist entry for a private-range host.
NOTIFY_APPRISE_EGRESS_CONFIRMEDfalseCloud-mode acknowledgement required before Apprise delivery runs.
NOTIFY_WEEKLY_ENABLED · NOTIFY_WEEKLY_CRONfalse · 0 8 * * 1Weekly 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_MS20 · 900000Failure-spike producer threshold and window — 20 failures in a 15-minute window.

Dashboard events

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.

VariableDefaultPurpose
EVENTS_ENABLEDtrueDashboard event stream toggle. Set false to turn it off.
EVENTS_HEARTBEAT_MS25000Keep-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_MS30000Low-rate verification read that confirms the stream is current. It verifies the stream rather than replacing it, so boot fails below 2500.
EVENTS_REVALIDATE_MSderivedServer-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_OWNER6Concurrent streams one account may hold (open tabs), range 1–64.
EVENTS_QUEUE_LIMIT256Bounded per-stream outbound queue, range 8–10 000.
EVENTS_NUDGE_COALESCE_MS1000Window over which analytics nudges are coalesced into one push. Minimum 1000.
Compose These reach the container only if your docker-compose.yml declares them in the app service's environment: block — it is an explicit allow-list, not a blanket pass-through. The shipped compose file lists them, so a current stack picks them up from .env; a compose file you wrote yourself, or one carried over from an older release, needs the keys added — or an env_file: as the prebuilt-image compose in the README does — otherwise setting them in .env silently leaves the event stream on its defaults.

Observability

Prometheus metrics on by default, plus opt-in OpenTelemetry tracing. Health is always at /api/health.

VariableDefaultPurpose
OTEL_ENABLEDfalseOpenTelemetry tracing toggle.
OTEL_EXPORTER_OTLP_ENDPOINTSDK defaultOTLP/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_ENDPOINTunsetPer-signal traces override. The exporter reads it directly and it takes precedence over the generic endpoint.
OTEL_SERVICE_NAMEpolyrouterService name attached to exported spans.
METRICS_ENABLEDtruePrometheus /metrics endpoint toggle.
Security & privacy Operations