Self-host
Run your own routess instance with Docker Compose.
This walkthrough gets a routess instance running on your own machine or server in about five minutes. For Kubernetes, see Kubernetes.
What you need
- Docker and Docker Compose v2 (modern Docker Desktop or Docker Engine ships with both).
- A Google OAuth client for Google sign-in (the compose file requires it). Users can also sign in with email + password once the stack is up.
- A Mapbox access token; the free tier is fine.
- A Valhalla routing engine (
VALHALLA_URL). Route calculation and surface analysis go through it; without it the routing endpoints return 503. - Long random strings for
JWT_SECRET,ANALYTICS_SALT,PAT_PEPPER, andDB_PASSWORD. Generate withopenssl rand -base64 32.
Optional, gracefully disabled when unset:
- Resend for transactional email (without it, verification URLs are logged to stdout).
- Sentry / GlitchTip for browser error reporting.
- Umami for product analytics.
- Admin role for accessing the in-app admin panel.
Set up Google OAuth
- Open the Google Cloud Console credentials page.
- Create an OAuth 2.0 Client ID of type Web application.
- Under Authorized JavaScript origins, add the URL your users will open (e.g.
http://localhost:8080orhttps://routess.example.com). - Under Authorized redirect URIs, add
<your-web-url>/auth/callback. - Copy the Client ID and Client secret.
Get a Mapbox token
- Sign up at account.mapbox.com.
- Create a public access token (
pk.…) under Access tokens. - Restrict it to your domain when you go to production.
Run it
git clone https://github.com/robbeverhelst/routess.git
cd routess
cp docker/.env.selfhost.example .env
# edit .env and fill in the required values
docker compose -f docker/compose.selfhost.yaml --project-directory . up -dThen open http://localhost:8080.
The stack starts four containers:
| Service | Image | Default port |
|---|---|---|
| Postgres | postgres:18 | (internal) |
| API | ghcr.io/robbeverhelst/routess-api | 3000 |
| Web | ghcr.io/robbeverhelst/routess-web | 8080 |
| Docs | ghcr.io/robbeverhelst/routess-docs | 3002 |
Change the host ports via WEB_PORT, API_PORT, DOCS_PORT in .env.
Pin a version
Default tag is latest. For production, pin to a specific release:
ROUTESS_TAG=1.122.7 docker compose -f docker/compose.selfhost.yaml --project-directory . up -dOr set ROUTESS_TAG in .env. See the releases page for available tags.
Environment variables
The .env file in docker/.env.selfhost.example is the source of truth. The variables you'll touch most often:
| Variable | Required | Default | Purpose |
|---|---|---|---|
JWT_SECRET | yes | - | Signs session and API tokens. Rotating logs everyone out. |
ANALYTICS_SALT | yes | - | Hashes user IDs in analytics events. Rotating resets the analytics pseudonyms. |
PAT_PEPPER | yes | - | Hashes personal access tokens at rest. Rotating invalidates every existing PAT. |
DB_PASSWORD | yes | - | Postgres password. |
GOOGLE_CLIENT_ID/SECRET | yes | - | Google OAuth credentials. |
VITE_MAPBOX_ACCESS_TOKEN | yes | - | Mapbox access token used by the web app. |
PUBLIC_WEB_URL | no | http://localhost:8080 | Where users open the app. Used as OAuth redirect base. |
PUBLIC_API_URL | no | http://localhost:3000 | Where the web app reaches the API. |
ADMIN_EMAILS | no | empty | Comma-separated emails granted the admin role on login. Empty disables the admin panel. |
RESEND_API_KEY | no | empty | Resend API key. Empty falls back to logging verification URLs to stdout. |
EMAIL_FROM | no | routess <[email protected]> | Sender address used by Resend. |
VALHALLA_URL | yes | empty | Base URL of a self-hosted Valhalla instance for routing and surface analysis. Empty makes the routing endpoints return 503. |
VITE_UMAMI_URL / _WEBSITE_ID | no | empty | Umami endpoint and site ID for the web app. Empty disables product analytics. |
UMAMI_URL / _WEBSITE_ID | no | empty | Same Umami endpoint and site ID for the docs site (Next reads them server-side). |
VITE_SENTRY_DSN | no | empty | Sentry / GlitchTip DSN for browser error reporting. |
VITE_SENTRY_ENVIRONMENT | no | production | Environment tag attached to browser error reports. |
PUBLIC_DOCS_URL | no | http://localhost:3002 | Canonical origin of the docs site, used in its sitemap, robots.txt, and llms.txt links. |
Advanced variables
The API understands more knobs than the compose file passes through. To use one, add it under the api service environment in docker/compose.selfhost.yaml (or your own override file):
| Variable | Default | Purpose |
|---|---|---|
LOG_LEVEL | info | Pino log level for the API (debug, info, warn, error). |
SESSION_TTL_DAYS | 7 | How long a browser session stays valid. |
SESSION_COOKIE_NAME | routess_session | Name of the HTTP-only session cookie. |
JWT_EXPIRES_IN | 7d | JWT token lifetime. |
FRONTEND_URLS | empty | Comma- or newline-separated list of allowed web origins; overrides FRONTEND_URL when set. |
SWAGGER_ENABLED | false in prod | Serve interactive API docs at SWAGGER_PATH. |
SWAGGER_PATH | /api | Path for the Swagger UI when enabled. |
TELEMETRY_ENABLED | true | Master switch for OpenTelemetry (metrics + traces). |
METRICS_ENABLED | true | Prometheus metrics at /metrics on the API port. Returns 503 when disabled. |
OTEL_EXPORTER_OTLP_ENDPOINT | empty | OTLP collector base URL for traces (/v1/traces is appended). Empty disables trace export. |
OTEL_EXPORTER_OTLP_HEADERS | empty | Extra OTLP headers as a JSON object, e.g. {"authorization":"Bearer …"}. |
GRAFANA_URLS | empty | JSON object of dashboard links shown in the admin panel: {"apiOverview":"…","apiLatency":"…","runtime":"…"}. |
For the web container:
| Variable | Default | Purpose |
|---|---|---|
VITE_SENTRY_TRACES_SAMPLE_RATE | 0.1 | Fraction of browser sessions traced when Sentry is enabled. |
VITE_SENTRY_DEBUG | false | Verbose Sentry SDK logging. |
Browser console logging defaults to warn. To debug a single browser, set localStorage["routess:log-level"] to debug, info, warn, error, or none and reload.
Billing (hosted product only)
Routess.com sells a Pro year pass (ADR 0039). A self-hosted instance leaves billing off, which is the default: every feature stays unlocked, everyone gets GENERATION_QUOTA_PER_DAY route generations a day, and no payment provider is contacted. Keep BILLING_ENABLED=false, or leave it unset.
For reference, these are the variables the hosted product sets. With BILLING_ENABLED=true the API refuses to start unless the launch moment and all three Stripe values are set.
| Variable | Default | Purpose |
|---|---|---|
BILLING_ENABLED | false | Switches the Pro pass, the paywall and the per-tier generation quota on. |
BILLING_PROVIDER | stripe | Payment provider. Only stripe is implemented. |
BILLING_LAUNCHED_AT | empty | ISO timestamp of the billing launch. Accounts created before it get 3 months of Pro on their next login. |
STRIPE_SECRET_KEY | empty | Stripe secret key (sk_live_… or a restricted rk_live_…). |
STRIPE_WEBHOOK_SECRET | empty | Signing secret (whsec_…) of the webhook endpoint <api>/api/v1/billing/webhook. |
STRIPE_PRICE_PRO_YEAR_PASS | empty | Stripe Price id of the one-off Pro year pass. |
GENERATION_QUOTA_PER_DAY_ANONYMOUS | 1 | Daily route generations for signed-out visitors (per IP) while billing is on. 0 disables the cap. |
GENERATION_QUOTA_PER_DAY_FREE | 3 | Same for signed-in users on the free plan. |
GENERATION_QUOTA_PER_DAY_PRO | 50 | Same for Pro users. |
Put it behind a reverse proxy
The default compose exposes ports directly. For TLS and a real domain, front the web and api services with a reverse proxy (Caddy, Traefik, ingress-nginx, Cloudflare Tunnel) and override the public URLs in .env:
PUBLIC_WEB_URL=https://routess.example.com
PUBLIC_API_URL=https://api.routess.example.comPUBLIC_WEB_URL is also used as the OAuth redirect base, so it must match what you configured in Google Cloud.
Upgrade
Take a database backup first (see below); migrations are forward-only, so a snapshot is your rollback path. Then:
docker compose -f docker/compose.selfhost.yaml --project-directory . pull
docker compose -f docker/compose.selfhost.yaml --project-directory . up -dThe API runs migrations automatically on startup. There is no manual migration step. To roll back, restore the pre-upgrade dump and pin ROUTESS_TAG to the previous release.
Back up
The Postgres data lives in the postgres_data named volume. Standard pg_dump works:
docker compose -f docker/compose.selfhost.yaml --project-directory . exec postgres \
pg_dump -U routess routess > routess-$(date +%F).sqlRestore into a fresh volume with psql:
docker compose -f docker/compose.selfhost.yaml --project-directory . exec -T postgres \
psql -U routess routess < routess-2026-06-04.sqlTroubleshooting
- Is the API healthy?
curl http://localhost:3000/health/readyreturns 200 when the API can reach the database;/health/livechecks only that the process is up. - "address already in use" on
:8080or:3000: changeWEB_PORT/API_PORTin.env. - Google sign-in returns
redirect_uri_mismatch: the OAuth client's authorised redirect URI must exactly match<PUBLIC_WEB_URL>/auth/callback. - Map tiles don't load: verify
VITE_MAPBOX_ACCESS_TOKENis set; check browser devtools for 401s fromapi.mapbox.com. - Verification emails not arriving: set
RESEND_API_KEYandEMAIL_FROM. Without them the API logs the verification URL to stdout; rundocker compose logs api | grep '\[email:console\]'.