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
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.
# uses your working tree — same install script, same secrets
git clone https://github.com/izzoa/polyrouter.git
cd polyrouter && ./install.sh
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:
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.
# 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.
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.
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
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.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
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:
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.
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:
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)
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).
# 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.
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.
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
prc exec postgres \
pg_dump -U polyrouter polyrouter > backup.sql
# restore
prc exec -T postgres \
psql -U polyrouter polyrouter < backup.sql
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.
# 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
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:
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
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.
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.
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.
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.
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.
# 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
The current architecture is designed for single-replica self-hosted deployment. The shared, cross-instance state that matters already lives in Redis:
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.