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

Deploy and operate.

polyrouter self-hosts with Docker Compose: one app container serving the SPA, API, and proxy on a single port, next to PostgreSQL 16 and Redis 7. A one-command installer bootstraps the whole stack, and every day-two task — upgrades, backups, health checks, metrics, notifications — is a short compose command. This is the operational runbook.

Maintained by Anthony Izzo · Last updated

Install

One command checks Docker, fetches a source archive at a single ref (main unless you set POLYROUTER_REF), generates cryptographic secrets into a mode-600 .env, and boots the stack. The install is idempotent — re-running refreshes the source while preserving your .env and data. Migrations run on boot.

$ curl -fsSL https://polyrouter.app/install.sh | sh
OR FROM A CHECKOUT
# uses your working tree — same install script, same secrets
git clone https://github.com/izzoa/polyrouter.git
cd polyrouter && ./install.sh
What the installer does
  1. Verifies Docker and Compose v2 are installed, and warns when the disk is low.
  2. Downloads a source archive at POLYROUTER_REF (default main; pass a commit SHA to pin).
  3. Generates the required secrets into a mode-600 .envonce. A re-run reuses the file it finds and never mints new keys.
  4. Stops before booting if the published port is already held by something that is not this stack, then brings the stack up with docker compose up -d --build (or pulls and boots without building when POLYROUTER_IMAGE is set).
  5. Polls /api/health for about two minutes, then prints the manage command — or, if it never answers, the container status and a logs command to read.
Note The app binds loopback (127.0.0.1:3001) by default — expose it only behind a reverse proxy. The first account to sign up becomes the admin, so open http://localhost:3001 and create it before exposing anything. When you do expose it, set POLYROUTER_HOST=0.0.0.0 and APP_URL — the real origin auth callbacks and cookies follow — in .env; the installer leaves both as commented hints.
Running docker compose against your install

The installer keeps your .env in polyrouter/ and the source — including the compose file — one level down in polyrouter/src/. A bare docker compose … run from either directory fails: there is no compose file in the first and no .env in the second. Every command on this page assumes the alias below, run from the polyrouter/ directory the installer created:

DEFINE ONCE · RUN FROM polyrouter/
alias prc='docker compose -p polyrouter-selfhost \
  --env-file ./.env \
  -f src/docker-compose.yml \
  --project-directory src'

This is the same invocation install.sh uses internally. If you installed from a git checkout instead, the compose file and .env sit together in the repo root — there alias prc='docker compose -p polyrouter-selfhost' is enough.

Uninstall
REMOVE THE STACK · THE INSTALLER DOES IT TOO
# stop and remove the containers, keep the data volumes
./install.sh --uninstall

# ...and delete the PostgreSQL and Redis volumes too
./install.sh --uninstall --purge

Run it from the checkout, from the install directory, or from its parent — the script finds the stack either way. --purge is destructive, so it makes you type the project name to confirm unless you pass --yes. Your .env is never deleted.

The stack

One container serves the SPA, API, and proxy on a single port, next to PostgreSQL 16 and Redis 7. On SIGTERM the app refuses new inference and drains what is already running, so a deploy lets in-flight streams finish rather than severing them at SIGTERM.

app
SPA · API · proxy
One container on port 3001. Refuses new inference and waits up to 15s for in-flight streams to finish, aborting any still open at that deadline; Compose allows 45s before SIGKILL (stop_grace_period: 45s).
postgres:16-alpine
PostgreSQL 16
Primary datastore. Schema migrations run automatically on every boot.
redis:7-alpine
Redis 7
Shared circuit-breaker, budget-counter, and notification-queue state across instances.
DOCKER-COMPOSE.YML · EXCERPT
name: polyrouter-selfhost

services:
  app:
    build: .
    image: ${POLYROUTER_IMAGE:-polyrouter:latest}   # set POLYROUTER_IMAGE to run a published GHCR image
    ports:
      - '${POLYROUTER_HOST:-127.0.0.1}:${POLYROUTER_PORT:-3001}:3001'
    stop_grace_period: 45s   # drain in-flight streams on SIGTERM

  postgres:
    image: postgres:16-alpine

  redis:
    image: redis:7-alpine
Note Run a single app replica. Boot migrations take no advisory lock, so never --scale app — the shared breaker and budget state that would make replicas safe already live in Redis, not the app process. See scaling notes.

Images

Multi-arch images (amd64 + arm64) are published to GHCR on every release tag. The baseline image is the default; a batteries-included -semantic variant adds the optional embedder, which supplies both Layer 2 semantic routing and the semantic workload classes (research, writing) — the same model, one embed per request serving both.

GHCR TAGS
ghcr.io/izzoa/polyrouter:latest
ghcr.io/izzoa/polyrouter:0.16.0
ghcr.io/izzoa/polyrouter:0.16
ghcr.io/izzoa/polyrouter:latest-semantic     # Layer 2, batteries-included
ghcr.io/izzoa/polyrouter:0.16.0-semantic
ghcr.io/izzoa/polyrouter:0.16-semantic
The -semantic variant

Built on a glibc (Debian) base so the ONNX runtime's prebuilt binaries load. It pins onnxruntime-node@1.27.0, pre-bakes the reference MiniLM model (all-MiniLM-L6-v2, Apache-2.0, 384-dim) with a checksum pin, and presets SEMANTIC_MODEL_PATH. Bring it up by layering the override compose file over the base:

RUN THE -SEMANTIC VARIANT · DEFINE A SECOND ALIAS
alias prcs='docker compose -p polyrouter-selfhost \
  --env-file ./.env \
  -f src/docker-compose.yml \
  -f src/docker-compose.semantic.yml \
  --project-directory src'

prcs up -d

The overlay only works layered on top of the base file, so carry both -f flags on every command for this stack. A plain prc up -d afterwards recreates app from the baseline target and image — losing the embedder without saying so.

The overlay sets ROUTING_AUTO_LAYERS to structural,semantic,cascade unless your .env overrides it, so the layer is live from first boot and only the per-account toggle is left — see Routing for how the layer decides.

Bring your own model

Mount a v1 bundle over the baked one and repoint SEMANTIC_MODEL_PATH — the same fail-fast boot contract applies (a broken bundle names the file and reason instead of running silently degraded). A bundle is three files:

BUNDLE CONTRACT · V1
models/minilm/
  manifest.json    # the v1 bundle contract (tokenizer + model spec)
  vocab.txt        # WordPiece vocabulary, one token per line
  model.onnx       # the embedding model (MiniLM class, 384-dim)
Note The baseline image carries no ONNX runtime and no model files — CI asserts that on every build. Turning on Layer 2 is a deliberate choice: run the -semantic image (or mount your own bundle) and have semantic in ROUTING_AUTO_LAYERS. Layering the overlay compose file sets that flag for you; pointing POLYROUTER_IMAGE at a -semantic tag on the base file alone does not. Nothing is ever fetched over the network at boot or runtime.

Upgrading

How you upgrade depends on how you installed. The default install builds from source, so refreshing the source and rebuilding is the upgrade; the published-image path applies only when you set POLYROUTER_IMAGE. Migrations run on boot, so there is no separate migration step. Back up first (below).

DEFAULT · SOURCE BUILD · RECOMMENDED
# idempotent — refreshes the source, preserves your .env and data
curl -fsSL https://polyrouter.app/install.sh | sh

# or, from a checkout you refreshed yourself
prc up -d --build

Without --build the stack just recreates the containers from the image you already built — the same code, restarted.

PUBLISHED IMAGE · ONLY WHEN POLYROUTER_IMAGE IS SET
prc pull
prc up -d

The compose file tags the app ${POLYROUTER_IMAGE:-polyrouter:latest}, and the default polyrouter:latest exists in no registry — so pull has nothing to fetch unless POLYROUTER_IMAGE points at a GHCR tag.

Note Read the release's Upgrade notes in the CHANGELOG before a jump. Migrations are additive — v0.14.0 added one, v0.15.0 added three — and nothing routes differently until you configure it, so an upgrade never silently changes where your traffic goes.

Backup & restore

Everything durable lives in PostgreSQL — the polyrouter-pg volume is the record of truth. The polyrouter-redis volume holds working state only: budget and rate counters, breaker state, the notification queue, and staged learning centroids. It is never authoritative, so back up Postgres. Dump and restore run against the postgres service with the usual tools.

DUMP & RESTORE
# dump
prc exec postgres \
  pg_dump -U polyrouter polyrouter > backup.sql

# restore
prc exec -T postgres \
  psql -U polyrouter polyrouter < backup.sql
Note Provider and notification credentials are stored AES-256-GCM encrypted, so a dump alone can't decrypt them. Your .env holds the keys (PROVIDER_CREDENTIAL_KEY and friends) — back it up separately and secretly, or a restored database can't read its own credentials. Those secrets are generated exactly once: if .env goes missing the installer refuses to mint new ones and tells you to restore it, because rotated keys break database auth and orphan every stored credential.

Health & logs

The app answers a liveness probe at /api/health with {"status":"ok"}; it reports only that the process is serving, not that Postgres or Redis are reachable. Point probes at that exact path — the SPA fallback answers a bare /health with 200 and a page of HTML, which looks healthy to a monitor and means nothing. The datastores answer their own. Logs stream from the app service.

RUNBOOK
# app health
curl http://localhost:3001/api/health

# follow app logs
prc logs -f app

# datastore liveness
prc exec postgres pg_isready
prc exec redis redis-cli ping
The container's own probe

Both images already declare a healthcheck, and it is byte-identical in each: an exec-form Node one-liner against /api/health on PORT, with no wget or curl involved — a wget probe would break on the -semantic image's Debian-slim base. If your orchestrator needs the probe declared in compose rather than inherited, use the same one:

ORCHESTRATOR OVERRIDE · THE SAME PROBE, COMPOSE FORM
healthcheck:
  test: ['CMD', 'node', '-e', "require('http').get('http://127.0.0.1:'+(process.env.PORT||3001)+'/api/health',r=>process.exit(r.statusCode===200?0:1)).on('error',()=>process.exit(1))"]
  interval: 30s
  timeout: 5s
  retries: 3
  start_period: 40s

Metrics & tracing

The app exposes a Prometheus /metrics endpoint (on by default) for your monitoring stack to scrape. OpenTelemetry tracing is opt-in — set the toggle and an OTLP/HTTP collector endpoint.

Note Both /metrics and /api/health are unauthenticated by design — scrapers and orchestrators expect that. Restrict them at the reverse proxy, or set METRICS_ENABLED=false to make /metrics a 404. What they expose is instance-level and metadata-only: no prompts, no keys, no per-tenant detail.
.ENV
METRICS_ENABLED=true                              # Prometheus /metrics (default on)
OTEL_ENABLED=true                                 # opt-in tracing (default off)
OTEL_SERVICE_NAME=polyrouter                      # resource name on every span
OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318
# OTEL_EXPORTER_OTLP_TRACES_ENDPOINT=            # traces-only override; leave it out, don't blank it

Every knob is Zod-validated at boot and fails fast on an out-of-range value — a blank endpoint is not the same as an unset one and will fail boot rather than disable the exporter. The full list lives in Configuration.

WHAT /METRICS EXPORTS
polyrouter_requests_total                    # by outcome
polyrouter_request_duration_seconds          # end-to-end histogram
polyrouter_tokens_total                      # prompt + completion
polyrouter_cost_microusd_total               # snapshotted cost, micro-USD
polyrouter_upstream_requests_total           # per provider attempt
polyrouter_upstream_duration_seconds         # per provider attempt
polyrouter_upstream_setup_failures_total     # failed before first token
polyrouter_breaker_state                     # 0 closed · 1 half-open · 2 open
polyrouter_breaker_opens_total
polyrouter_breaker_store_faults_total
polyrouter_budget_enforcement_faults_total
polyrouter_log_rows_dropped_total            # recording backpressure

Standard Node process metrics (heap, event-loop lag, GC) ship alongside them. Everything is an aggregate — there are no per-request series and no prompt content.

Notifications

Wire up SMTP and/or Apprise channels to get budget alerts and blocks, provider-down notices, failure-spike warnings, and an optional weekly summary. Everything is queued off the request path and deduped, so a failing channel never blocks a request or budget enforcement. The weekly summary stays off until NOTIFY_WEEKLY_ENABLED=true; once on it goes out Mondays at 08:00 UTC.

START THE APPRISE SIDECAR · OPT-IN PROFILE
prc --profile apprise up -d

The sidecar sits behind a compose profile, so a plain up -d never creates it. Setting APPRISE_API_URL without starting the sidecar — or without a matching NOTIFY_ALLOWED_ENDPOINTS entry — fails the app's boot-time SSRF validation and the container exits.

.ENV
# SMTP — active only when both SMTP_HOST and SMTP_FROM are set
SMTP_HOST=smtp.example.com
SMTP_FROM=alerts@example.com

# Apprise fan-out (optional) — an opt-in compose profile
APPRISE_API_URL=http://apprise:8000
NOTIFY_ALLOWED_ENDPOINTS=apprise,172.28.5.0/24,8000   # host,cidr[,port] — ';'-separated for multiple entries
Note The SSRF guard checks every Apprise target, so a private-range host needs a port-bounded NOTIFY_ALLOWED_ENDPOINTS entry to be reachable. Compose pins the stack's subnet so that entry is deterministic rather than whatever Docker hands out; on a collision with an existing network, set POLYROUTER_SUBNET and move the NOTIFY_ALLOWED_ENDPOINTS entry with it. Per-channel setup and the full env list live in Configuration; channels are managed in dashboard Settings.

Scaling notes

The current architecture is designed for single-replica self-hosted deployment. The shared, cross-instance state that matters already lives in Redis:

  • Circuit-breaker state lives in Redis, shared across replicas.
  • Budgets use atomic Redis counters that stay correct across instances.
  • The semantic learning sweep is per-tenant and idempotent (CAS + audit + promote) — multiple workers are safe.
  • Auth rate limits are an atomic Redis fixed window, shared across instances (with a per-instance fallback only while Redis is down).
  • Request recording buffers in-process, flushes on an interval, and drains on graceful shutdown — a hard kill loses whatever was unflushed, and drops surface as polyrouter_log_rows_dropped_total. Each replica keeps its own buffer, so this is a durability property, not a scaling blocker.
  • The dashboard's live event stream (/api/events) fans out in-process — a second replica would silently drop live updates for sessions it doesn't hold.
  • Boot migrations take no advisory lock — do not --scale app.
Behind a reverse proxy

Do not let the proxy buffer /api/events. polyrouter already sends X-Accel-Buffering: no and Cache-Control: no-cache, no-transform, and heartbeats every 25 seconds to keep idle connections open; nginx still needs proxy_buffering off; on that location. A blocked stream is not fatal — the dashboard's top bar degrades from Live to Polling — but you lose the real-time view.

Note Multi-replica is not a supported topology today. Beyond boot migrations, the dashboard's live event stream (/api/events) fans out in-process, so a second replica would silently drop live updates for sessions it doesn't hold; Redis pub/sub fanout is the named graduation. Auth rate limits and budget counters already coordinate through Redis and are not the blocker. For a single box, keep one app and let it drain streams on deploy.
Configuration Back to docs