Deployment¶
Production deployment guide for Routstr Provider nodes.
Routstr ships two Compose stacks. Pick one and use it for every Compose command on that node — they are separate stacks, so switching mid-setup starts a different configuration against the same data.
compose.yml (standard) |
compose.node.yml (guided, loopback) |
|
|---|---|---|
| Port binding | 8000 on all interfaces |
127.0.0.1 only (ROUTSTR_NODE_PORT, default 8000) |
| Tor hidden service | Yes | No |
| Nostr identity | Auto-generated (AUTO_GENERATE_NSEC=true) |
Auto-generated (AUTO_GENERATE_NSEC=true); nothing is published until a public HTTP_URL/onion endpoint is set |
| Analytics sharing | As configured | Off on first boot; change it in Settings |
| Compose command | docker compose … |
docker compose -f compose.node.yml … |
compose.node.yml sits behind a reverse proxy and is public by default: pass
a public URL to the setup script and the node publishes itself on first boot. Use
--private instead when you want nothing reachable from outside the host until
the admin password is rotated and an upstream is configured — for example on a
public VPS, or when an agent is doing the setup (agents: read
llms.txt first).
Running bare docker compose up always selects the standard stack.
Quick Start (Standard Stack)¶
For a new node the guided first run is the recommended path:
it sets up the loopback stack behind your reverse proxy and publishes on first
boot. The standard stack below is an alternative that bundles a Tor hidden
service and can be started with plain docker compose up.
The standard stack clones the repository at the latest release and starts it with Docker Compose. Compose builds both the node and the admin dashboard from source, so there is no image to pull and no dashboard build to keep in sync with the node.
git clone https://github.com/Routstr/routstr-core.git
cd routstr-core
# Check out a release (v0.4.7 is current — see the releases page for the newest tag)
git checkout v0.4.7
# Compose reads its configuration from .env
cp .env.example .env
docker compose up -d
Then open your node: - API & Admin Dashboard: http://localhost:8000 - Admin login: the password is generated and logged once on first start
docker compose logs routstr | grep -i admin
The first start takes a few minutes
docker compose up builds both images locally, and the Next.js dashboard
build is the slow part. Later starts reuse the built images.
Always tracking the newest release
To check out whatever releases/latest currently points at, use:
git clone https://github.com/Routstr/routstr-core.git
cd routstr-core
git checkout "$(curl -sSL -o /dev/null -w '%{url_effective}' \
https://github.com/Routstr/routstr-core/releases/latest | sed 's|.*/tag/||')"
Omitting the git checkout entirely leaves you on main — newer, but not a
tested release.
Port 8000 is public with the standard stack
compose.yml publishes port 8000 on every interface. On a host with a public
IP, firewall it (or use the guided first run) until you
have rotated the admin password.
Guided First Run¶
scripts/node_setup.py starts compose.node.yml and checks that it came up. It
needs Docker with the Compose plugin and Python 3 on the host. The setup is
public by default and expects DNS, TLS and a reverse proxy to already forward
an HTTPS origin to 127.0.0.1:8000; --private opts out.
git clone https://github.com/Routstr/routstr-core.git
cd routstr-core
git checkout v0.4.7 # or the reviewed release tag you are deploying
# Public (default): pre-flights DNS/TLS/proxy, then publishes on first boot
python3 scripts/node_setup.py start \
--public-url https://node.example \
--ln-address you@wallet.com
# Private: loopback only, nothing published until a public URL is set later
python3 scripts/node_setup.py start --private
start:
- pre-flights the public origin over HTTPS before starting, so the node never advertises a dead endpoint;
- creates
.envfrom.env.examplewith owner-only (0600) permissions when it does not exist, and only writes the keys it manages (HTTP_URL,RECEIVE_LN_ADDRESS,MIN_PAYOUT_SAT,PAYOUT_INTERVAL_SECONDS). The template's upstream pair is left commented, so a new node has no upstream until one is added in the dashboard. A conflictingHTTP_URLis an error andNSEC/ONION_URLare refused, so an operator edit is never silently clobbered. Review it and never commit it; - validates
--ln-address(auser@hostaddress is resolved to its LNURL-pay endpoint) — a saved address is still not a tested payout; - builds and starts
compose.node.yml, generates the master key and Nostr identity, then waits up to three minutes for/v1/infoand/v1/modelson the loopback port (the first build itself can take longer). Public mode publishes the listing; private mode publishes nothing. - mints a long-lived CLI token and writes
~/.routstr/config.json(0600; left untouched if it already points at another node) so the Routstr CLI can operate the node;--no-cli-tokenskips it. The token is full node admin — revoke it in Settings → CLI Tokens when you are done.
If port 8000 already belongs to another service, leave that service alone and pick a free port:
python3 scripts/node_setup.py start --private --port 18080
python3 scripts/node_setup.py check --port 18080
ROUTSTR_NODE_PORT=18080 docker compose -f compose.node.yml logs routstr
The dashboard is then at http://127.0.0.1:18080/admin. In public mode, point
the reverse proxy at that port instead of 8000.
Administer a Remote Host over SSH¶
The private stack is only reachable from the host itself. From your own computer, forward the port and open http://127.0.0.1:8000/admin in your local browser:
ssh -L 8000:127.0.0.1:8000 user@your-server
Rotate the Bootstrap Password¶
The admin password is generated and printed once to the container's stdout. Read it yourself in a private terminal, sign in, and change it straight away in Settings → Admin Settings → Change Admin Password:
docker compose -f compose.node.yml logs routstr | grep -i admin
Don't relay those logs through an agent or paste them into a chat, and don't publish a node that still uses its bootstrap password.
Check Readiness¶
After adding an upstream under Providers (see Phase 3 of the Quick Start):
python3 scripts/node_setup.py check
It prints the node name and public model count, and exits 2 when there are zero
models — the API is up but setup is incomplete. A passing check proves discovery
only: it does not test payments, streaming, upstream requests, refunds or payout.
See Production Acceptance.
What Docker Compose Starts¶
compose.yml brings up three services:
- ui — builds the Next.js admin dashboard and copies the result into the
shared
./ui_outvolume. - routstr — the Python node, serving the API and the dashboard built above.
- tor — serves the node as a
.onionhidden service, so no port forwarding is needed. See Tor Support for how to read your.onionaddress.
compose.node.yml brings up only ui and routstr. There is no Tor
service, the node port is bound to 127.0.0.1, ENABLE_ANALYTICS_SHARING is
off on first boot, and AUTO_GENERATE_NSEC=true creates the identity on first boot —
nothing is published until a public HTTP_URL (or onion endpoint) is set.
PostgreSQL (Optional)¶
Both stacks use SQLite (keys.db) unless you add compose.postgres.yml, which
starts a postgres service (PostgreSQL 16, data in the postgres-data
volume, not published to the host) and points the node at it. SQLite is fine
for a single node; switch when you want several workers or replicas sharing one
database.
The steps below work for a brand-new node and for one that is already running:
on the first start with PostgreSQL the node copies your existing keys.db into
the new database by itself. Your balances, API keys, providers, settings and
Nostr identity come across unchanged.
The examples use the private stack (compose.node.yml). On the standard stack,
use compose.yml wherever you see compose.node.yml. Plan a few minutes of
downtime: the node is stopped from step 1 until step 3 finishes.
1. Before you start¶
Note what the node looks like now, so you can compare afterwards: in the dashboard, write down the node's npub, the total on Balances, and your upstream providers. If you have an API key at hand, note its balance too.
Check where your database is. Run:
grep DATABASE_URL .env
ls -l keys.db*
If DATABASE_URL is not set, or is sqlite+aiosqlite:///keys.db, and ls
shows keys.db, you are on the default and can continue. If it points
somewhere else, see Your database is not ./keys.db
before going on. Don't start PostgreSQL until the file is found: a node that
finds no keys.db treats itself as a new installation and starts empty.
Stop the node and back it up. Everything below leaves these files in place, but the backup is your way back if anything surprises you:
docker compose -f compose.node.yml stop
mkdir -p ../routstr-backup
cp -a .env keys.db* ../routstr-backup/
[ -f routstr_secret.key ] && cp -a routstr_secret.key ../routstr-backup/
[ -d .wallet ] && cp -a .wallet ../routstr-backup/
ls -la ../routstr-backup
keys.db* also copies the -wal and -shm files, which are part of the
database. routstr_secret.key is missing if you set ROUTSTR_SECRET_KEY in
.env (then .env is the copy you need), and .wallet/ is missing until the
node has handled ecash; the two guarded lines simply skip what isn't there.
A brand-new node with no keys.db has nothing to back up; skip to step 2.
2. Tell Compose to always include PostgreSQL¶
Add two lines to .env:
echo "POSTGRES_PASSWORD=$(openssl rand -hex 32)" >> .env
echo "COMPOSE_FILE=compose.node.yml:compose.postgres.yml" >> .env
POSTGRES_PASSWORDis set once; keep the same password across restarts. PostgreSQL stores it when the database is first created, so changing it later locks the node out of its own data.COMPOSE_FILEmakes everydocker composecommand in this directory use both files, as long as you don't pass-fyourself. From now on rundocker compose up -d --build,docker compose logs routstr,docker compose restart routstrand so on without-f.
Never pass -f compose.node.yml on its own again
An explicit -f replaces COMPOSE_FILE. docker compose -f compose.node.yml
up -d starts the node without PostgreSQL, on a new empty SQLite
database: users see "Key not found" and the node gets a new identity. Your
data is still safe in PostgreSQL; see Troubleshooting
to recover. The same goes for node_setup.py start, which always uses
compose.node.yml alone: don't re-run it after switching
(node_setup.py check is fine).
3. Start the node¶
docker compose up -d --build
docker compose logs -f routstr
The second command follows the log. The import runs while the node starts, so give it a moment and wait for one of these lines:
SQLite database imported into PostgreSQL: done. Press Ctrl-C to stop following the log; the node keeps running.SQLITE_IMPORT_PATH does not exist, so nothing is imported: expected on a brand-new node. On a node that had data, stop now (docker compose stop routstr) and recheck step 1; don't let it take traffic, or the import will be refused later.SQLite import did not run; nothing was copied and the node will not start: the lines above it give the reason. Nothing was half-copied: PostgreSQL keeps none of the rows andkeys.dbis untouched. To get the node back on SQLite while you look into it, follow Switching back to SQLite.
After a successful import keys.db, keys.db-wal and keys.db-shm are
renamed to keys.db.imported-<date and time> (plus -wal/-shm). Keep them:
they are your SQLite copy from the moment of the switch. Later starts skip the
import.
4. Check your data¶
Log in to the dashboard with your usual password and compare with what you noted in step 1:
- the node's npub is the same (dashboard, or
/v1/info), - Balances shows the same total,
- your upstream providers and settings are all there.
With an API key at hand, check its balance; replace sk-... with the key and
8000 with your ROUTSTR_NODE_PORT if you changed it:
curl -s -H "Authorization: Bearer sk-..." http://127.0.0.1:8000/v1/balance/info
If something doesn't match, stop the node (docker compose stop routstr)
before users spend on it, and switch back.
From now on, keep the same ROUTSTR_SECRET_KEY (or routstr_secret.key) and
.wallet/: the encrypted secrets in PostgreSQL need that key, and the Cashu
wallet stays on local SQLite in .wallet/. Your data now lives in the
postgres-data volume; back it up with pg_dump (see
Backup and Restore).
Switching back to SQLite¶
The imported file holds everything up to the switch. Anything written to
PostgreSQL afterwards (new keys, top-ups, spending) is not in it, and there
is no tool that copies PostgreSQL back into SQLite. Switching back is safe only
before the node has served users on PostgreSQL. After that, stay on PostgreSQL
and restore from a pg_dump if something goes wrong.
docker compose down # stops the node and PostgreSQL; PostgreSQL data stays in its volume
sed -i.bak '/^COMPOSE_FILE=/d' .env # back to the plain stack (.env.bak keeps the old file)
If the import succeeded, put the SQLite file back. List the archives and
copy the exact name of the one from this migration into archive=:
ls -l keys.db.imported-*
archive='keys.db.imported-20261028T173919Z' # replace with your file's name
stray=$(mktemp -d ../routstr-stray-sqlite.XXXXXX) && echo "moved aside to $stray"
for f in keys.db keys.db-wal keys.db-shm; do [ -f "$f" ] && mv "$f" "$stray/"; done
mv "$archive" keys.db
[ -f "$archive-wal" ] && mv "$archive-wal" keys.db-wal
[ -f "$archive-shm" ] && mv "$archive-shm" keys.db-shm
The for line moves any SQLite file a mistaken start created out of the way
first, so it can't mix with the restored one. If the import failed,
keys.db was never renamed; skip this block.
Then start the node on SQLite:
docker compose -f compose.node.yml up -d --build
PostgreSQL troubleshooting¶
-
Users get "Key not found", the npub changed, providers are gone. The node started without
compose.postgres.yml(an explicit-f compose.node.yml, ornode_setup.py start) and created a fresh SQLitekeys.db. Your data is safe in PostgreSQL. Move the new SQLite files aside, leavingkeys.db.imported-*alone, and start again without-f:docker compose stop routstr stray=$(mktemp -d ../routstr-stray-sqlite.XXXXXX) && echo "moved aside to $stray" for f in keys.db keys.db-wal keys.db-shm; do [ -f "$f" ] && mv "$f" "$stray/"; done grep COMPOSE_FILE .env # must print COMPOSE_FILE=compose.node.yml:compose.postgres.yml docker compose up -d --buildThen repeat step 4. If anyone topped up while the wrong node ran, those payments are only in the folder the first line printed; each incident gets its own folder. Refund or re-credit them by hand. - "keys.db changed after it was imported into PostgreSQL". Same cause, seen when you start PostgreSQL again: a SQLite node used
keys.dbafter the import. Move the files aside with the commands above. - "refusing to merge into it". PostgreSQL already holds data that did not come from an import, for example after the node once started there with nokeys.dbto import. If that data matters, setSQLITE_IMPORT_PATH=(empty) in.envto keep using it. If it holds nothing you need,docker compose down -vdeletes thepostgres-datavolume (everything in PostgreSQL is gone), and the nextdocker compose up -d --buildimportskeys.db. - Your database is not./keys.db. IfDATABASE_URLin.envpoints to another file, back up that file (and its-wal/-shm) in step 1 instead, and before step 3 addSQLITE_IMPORT_PATH=with the file's path inside the container to.env. The checkout is mounted at/app, sosqlite+aiosqlite:///data/keys.dbbecomesSQLITE_IMPORT_PATH=/app/data/keys.db.
All import rules are in Configuration.
Pre-Configuration (Optional)¶
Everything can be configured from the dashboard after first start, but you can
pre-configure a deployment by editing the .env file you created above:
# Upstream (optional — prefer the dashboard, where providers are managed).
# Uncommenting this pair seeds one enabled "custom" provider on the node's first
# boot; the values are saved in Settings afterwards, so dashboard changes and
# later `.env` edits win over it.
# UPSTREAM_BASE_URL=https://api.openai.com/v1
# UPSTREAM_API_KEY=sk-proj-...
# Encrypts node secrets at rest. Optional — if unset, a key is generated next to
# your database (on the same volume) and its file is named once for backup. Set
# it explicitly to manage the key yourself.
ROUTSTR_SECRET_KEY=
# Node identity
NAME=My Provider Node
DESCRIPTION=Fast GPT-4 access via Lightning
# Lightning withdrawals
RECEIVE_LN_ADDRESS=me@walletofsatoshi.com
The admin password is generated and logged once on first start; set
ADMIN_PASSWORD only as a legacy seed for an existing deployment.
The Nostr identity is also automatic: compose.yml sets
AUTO_GENERATE_NSEC=true, so the node creates an nsec on first boot and stores
it encrypted. Only its npub is logged; retrieve the nsec with
docker compose exec routstr /.venv/bin/python scripts/reveal_nsec.py (or
python scripts/reveal_nsec.py from a source checkout; it requires
ROUTSTR_SECRET_KEY or the persisted key file). Set NSEC only to import a
specific identity, or AUTO_GENERATE_NSEC=false to configure one from the
dashboard instead.
With compose.node.yml the identity is generated automatically too; a node stays
undiscoverable until a public HTTP_URL (or onion endpoint) is set. Configure
approved relays in the dashboard when you are ready to be listed.
Secret key persistence
If you leave ROUTSTR_SECRET_KEY unset, the node generates one and stores it
as routstr_secret.key next to your database, so it persists alongside
your data — just include that in your backups. For stronger isolation
(keeping the key off the data volume), set ROUTSTR_SECRET_KEY from a
secrets manager instead.
Analytics Sharing on the Guided Stack¶
compose.node.yml starts the node with analytics sharing off. The first boot
saves that value in the node's settings, and saved settings win over the
environment on every later boot, so editing ENABLE_ANALYTICS_SHARING in .env
or Compose has no effect afterwards. Turn it on or off under Settings → Share
Analytics in the dashboard, and only with the operator's consent.
Analytics publishing does not depend on HTTP_URL: once enabled, a node with a
Nostr identity publishes usage snapshots to its relays even in private mode.
See Configuration for all available options.
Persistence¶
With either Compose file the repository directory is mounted into the container, so everything Routstr persists stays in the directory you cloned:
| Path | Contents |
|---|---|
keys.db |
SQLite database (settings, API keys, sessions) |
keys.db-wal, keys.db-shm |
SQLite sidecar files, when present — part of the database |
routstr_secret.key |
Auto-generated master key, written beside the database when ROUTSTR_SECRET_KEY is unset (or wherever ROUTSTR_SECRET_KEY_FILE points) |
.wallet/ |
Cashu wallet data (your Bitcoin!); created the first time the node handles ecash |
.env |
Node configuration and any seeded secrets |
logs/ |
Node logs |
Back Up Your Data
Your cloned directory holds your wallet and your master key. Losing it means
losing funds. Back it up regularly — and don't delete the checkout to
"start fresh" without copying keys.db, routstr_secret.key and .wallet/
first.
Backup and Restore¶
- Back up with all writers stopped (
docker compose stop, ordocker compose -f compose.node.yml stop), or take a consistent backup of an external database (pg_dumpfor thepostgresservice). Includekeys.dbwith any-wal/-shmsidecars,routstr_secret.key(or your secret-manager key),.wallet/,.env, the Compose file(s) you use, and the node's Nostr identity. - Keep the database and its encryption key together in an encrypted, off-host backup. Encrypted secrets can't be recovered without the original key.
- Test restores on an isolated host with no relay publishing or payout activity. Never run two copies of the same wallet at the same time.
- Never delete volumes or wallet state to fix a login problem. Reset the password instead (below).
On PostgreSQL (compose.postgres.yml), use pg_dump. Replace routstr with
your POSTGRES_USER/POSTGRES_DB if you changed them.
Back up (the node can keep running):
dump="routstr-$(date +%F-%H%M%S).sql"
[ ! -e "$dump" ] &&
docker compose exec -T postgres pg_dump -U routstr routstr > "$dump" &&
echo "backup OK: $dump" || echo "BACKUP FAILED: don't use $dump"
Every backup gets its own file name, so an earlier backup is never overwritten.
Only a run that prints backup OK produced a usable file. Keep the dump
together with .env (it holds POSTGRES_PASSWORD), routstr_secret.key and
.wallet/.
Restore replaces everything in PostgreSQL with the dump, so it is a separate
step. Set dump to the backup you want, then run the rest as one block:
dump='routstr-2026-10-28-153000.sql' # replace with your backup's file name
test -s "$dump" &&
cp "$dump" "$dump.keep" &&
docker compose down -v &&
docker compose up -d --wait postgres &&
docker compose exec -T postgres psql -v ON_ERROR_STOP=1 -U routstr -d routstr < "$dump" &&
docker compose up -d --build &&
echo "restore OK"
Each line runs only if the one before it succeeded. test -s stops before
anything is touched if the file is missing or empty; down -v then deletes the
current PostgreSQL data, --wait holds until the new database is ready, and the
node starts only after the whole dump loaded. If you don't see restore OK, the
node stays stopped: fix the cause and run the block again (the spare copy
$dump.keep is still there). Afterwards,
repeat step 4 of the migration to check the data. The
restored database still records the original import, so the node doesn't
import keys.db again.
Admin Password Recovery¶
Lost the admin password? Clear it from a private terminal and restart the node; the restart generates and logs a new one-time password (the API is briefly unavailable). Then rotate it again in Settings → Admin Settings:
docker compose exec routstr /.venv/bin/python scripts/reset_admin_password.py --regenerate
docker compose restart routstr
# private stack:
docker compose -f compose.node.yml exec routstr /.venv/bin/python scripts/reset_admin_password.py --regenerate
docker compose -f compose.node.yml restart routstr
Reverse Proxy (Optional)¶
For custom domains and SSL, use a reverse proxy like Caddy or nginx.
Caddy Example¶
api.yournode.com {
reverse_proxy localhost:8000
}
nginx Example¶
server {
listen 443 ssl;
server_name api.yournode.com;
ssl_certificate /path/to/cert.pem;
ssl_certificate_key /path/to/key.pem;
location / {
proxy_pass http://localhost:8000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
If you started with --port, proxy to that port instead of 8000.
Publishing a Node¶
Before you expose a node, promote it deliberately. In public mode
(node_setup.py start --public-url …) the endpoint and listing already exist, so
only steps 1–2 remain; when you started in private mode (or with
compose.yml), work through all of them:
- Rotate the bootstrap admin password.
- Configure an upstream with enabled models, and confirm with
python3 scripts/node_setup.py check. - Choose a public HTTPS domain, set up DNS and certificates, firewall the host,
and run the reverse proxy on the same host. Only
443should be publicly routed — keep the node port on loopback (compose.node.ymldoes this; withcompose.yml, firewall port8000). Optionally, restrict administrative endpoints to trusted clients at the proxy — the node's admin password is the required protection. - Decide explicitly whether the node should be discoverable on Nostr (see Discovery) and whether to share analytics. Setting the URL in the next step publishes the listing immediately.
- In Settings → Admin Settings, set HTTP URL to the real public HTTPS origin — the node publishes its listing as soon as it is set.
From another machine with a checkout of the repository, verify the public endpoint (normal hostname and certificate checks apply):
python3 scripts/node_setup.py check --public-url https://api.yournode.com
Confirm it reports the expected node name and a nonzero model count.
Production Acceptance¶
/v1/info and /v1/models are not a health or paid-inference test. Before
calling a node production-ready:
- make a small, budget-approved Cashu-paid completion request, both non-streaming and streaming, using a funded client credential (not an admin token);
- reconcile usage, upstream charges and refunds;
- test payout separately;
- verify a backup restore as described above.
Updates¶
Before you change anything
Record the current tag (git describe --tags) and read the release notes for
the version you are moving to. Then back up the database your node uses now:
- SQLite: stop the node and copy
keys.dbwith its-wal/-shmfiles. - PostgreSQL: run the Back up block in
Backup and Restore. The
keys.db.imported-*file from the migration does not contain anything written since.
In both cases also keep .env, routstr_secret.key (or your
ROUTSTR_SECRET_KEY) and .wallet/ if it exists.
Then check out the new release and rebuild with the one command that matches how your node runs:
git fetch --tags
git checkout v0.4.7 # or the tag you are moving to
-
Standard stack, or any node on PostgreSQL (
COMPOSE_FILEset in.env, see PostgreSQL):docker compose up -d --build -
Private stack on SQLite:
docker compose -f compose.node.yml up -d --buildNever use this one after moving to PostgreSQL: naming only
compose.node.ymlstarts the node on an empty SQLite database.
--build is required: Compose reuses an existing image for a service unless you
ask it to rebuild.
Rolling back across a database migration may need the matching database and
wallet restored from backup, not just an older image. After an update, re-run the
local and public node_setup.py check and your approved paid-inference checks.
Building Without Starting¶
docker compose up -d already builds from source. To build the images
explicitly without starting them:
docker compose build
# private stack:
docker compose -f compose.node.yml build
To build only the node image (the dashboard must already be built into
./ui_out):
docker build -t routstr-node .