How it works Features Docs Compare GitHub ↗
Self-host it Log in
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.

Install

One command checks Docker, fetches a pinned source archive, 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.
  2. Downloads a pinned source archive.
  3. Generates the required secrets into a mode-600 .env.
  4. Builds the image and boots the stack with docker compose up -d --build (or pulls and boots without building when POLYROUTER_IMAGE is set), then waits for /api/health before reporting success.
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.
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.

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 Layer 2 embedder.

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

Built on a glibc (Debian) base so the ONNX runtime's prebuilt binaries load. It pins [email protected], 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
prc -f src/docker-compose.semantic.yml up -d

The embedder still only runs when semantic is in ROUTING_AUTO_LAYERS and a tenant opts in — 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 add semantic to ROUTING_AUTO_LAYERS. 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.

Backup & restore

Everything durable lives in PostgreSQL. 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.

Health & logs

The app answers a liveness probe at /api/health; 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

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.

.ENV
METRICS_ENABLED=true # Prometheus /metrics (default on) OTEL_ENABLED=true # opt-in tracing (default off) OTEL_EXPORTER_OTLP_ENDPOINT=http://otel-collector:4318

Every knob is Zod-validated at boot and fails fast on an out-of-range value — the full list lives in Configuration.

Notifications

Wire up SMTP and/or Apprise channels to get budget alerts and blocks, provider-down notices, failure-spike warnings, and a weekly summary. Everything is queued off the request path and deduped, so a failing channel never blocks a request or budget enforcement.

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 [email protected] # 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. 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 uses a single-replica background writer.
  • 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.
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