7.8 KiB
Operator setup
Status: stable · Updated 2026-09-27 · Applies to: independent backend operators
Deploy a smirk-backend-core instance. It holds no seed and no spend key; it
forwards view credentials to your chain backends per request and relays signed
bytes. Self-hosting changes which operator receives view credentials and network
metadata; it does not remove those data flows. See privacy boundaries.
Company production deployments use reviewed Fleet plan/apply procedures rather
than the manual setup examples below.
Prerequisites
- Rust (stable) and PostgreSQL 14+.
- A chain backend for each chain you enable (all optional, feature-flagged):
- Bitcoin / Litecoin — an Electrum/Fulcrum server (your own, or public).
- Monero / Wownero — a light-wallet-server (LWS) and a daemon.
- Grin — grin-wallet (owner/foreign API) and a grin node.
- A reverse proxy terminating TLS in front of the public port (production).
1. Database
createdb smirk_backend_core # or point DATABASE_URL at an existing database
Migrations run automatically on first start.
2. Secrets and configuration
cp .env.example .env
Generate each required secret with openssl rand -hex 32:
JWT_SECRET,SEED_FINGERPRINT_PEPPER,REFRESH_TOKEN_PEPPER,IP_SALT— always required.ADMIN_JWT_SECRET,ADMIN_KEY_INTEGRITY_SECRET— required whenADMIN_ENABLED=true. Back upADMIN_KEY_INTEGRITY_SECRET; losing it locks out admin.
Then set:
DATABASE_URL— the database from step 1.PUBLIC_API_URL— the public absolute base URL, e.g.https://backend.example.org/api/v1. It is the canonical value NIP-98 tokens bind (never the requestHost), and is required for Nostr identity and erasure.ENVIRONMENT=production— enforces HTTPS on external URLs and rejects placeholder secrets.
The server fails closed: it refuses to start on a missing or weak secret, or an
inconsistent feature configuration. .env.example documents every key.
3. Chain backends
Enable a chain with its FEATURE_* flag and configure its source. A chain whose
flag is on but whose source is unconfigured reports enabled: false via
/capabilities instead of failing at call time.
| Chain | Keys |
|---|---|
| BTC / LTC | {BTC,LTC}_ELECTRUM_URL (primary) and {BTC,LTC}_ELECTRUM_FALLBACKS (comma-separated). ssl:// / tls:// verify TLS with hostname checking; tcp:// is plaintext (LAN only). Primary is tried first, then fallbacks in random order. |
| XMR / WOW | {XMR,WOW}_LWS_URL, {XMR,WOW}_LWS_ADMIN_URL, {XMR,WOW}_LWS_ADMIN_KEY, {XMR,WOW}_DAEMON_URL. |
| Grin | GRIN_OWNER_API_URL + GRIN_OWNER_API_SECRET, GRIN_WALLET_PASSWORD, GRIN_FOREIGN_API_URL, and the node's GRIN_NODE_*. |
Electrum fallbacks are a privacy decision.
{BTC,LTC}_ELECTRUM_FALLBACKSare third-party servers. If your own Electrum/Fulcrum becomes unreachable, the backend fails over to them and the queries it sends carry your users' addresses.The failover is silent and correct, which is the trap: the only symptom is that balances keep working. Our own Litecoin Fulcrum was dead for 11 days before anyone noticed, with every lookup going to public servers in the meantime.
Decide deliberately:
- Keep fallbacks for availability. Balances survive your node dying. The backend logs
third-party Electrum fallback(s) configuredat startup so the choice is visible in your logs, and you should alert on your own Electrum being down rather than relying on users to notice.- Leave them empty to fail closed. Balances break when your node breaks, and every query stays on infrastructure you control. This is the right default for a privacy-first or air-gapped instance.
There is no third option where a dead node is both invisible and private.
4. Run
cargo run --release # or run the built ./target/release/smirk-backend-core
Verify:
curl -s localhost:8080/health
curl -s localhost:8080/api/v1/capabilities # enabled chains, features, restore + registration policy
5. First admin (optional)
Admin functions — key management, erasure, tamper-evident audit — run on a
separate loopback listener; the public API needs no admin. To enable it, set
ADMIN_ENABLED=true and the two admin secrets, then bootstrap with one of
two mutually-exclusive first-run commands:
# A) You already have a Sign-in-with-Smirk (Nostr) key — seed its pubkey (active now):
smirk-admin setup --pubkey <x-only-hex> # seed the first admin + latch the bootstrap
# B) You want the CLI to generate a key — writes the secret 0600, registers it
# PENDING, and latches in one step (no `setup` needed):
smirk-admin create-admin-wallet --out admin-key.hex
smirk-admin doctor # database, live keys, audit chain
<x-only-hex> is the admin's Sign-in-with-Smirk (Nostr) public key; the admin
authenticates with the matching key over NIP-98. Both commands latch the
bootstrap, so run only one — after either, the other is refused by design (a
live admin key already exists). With (B) the generated key is pending and
activates on the holder's first NIP-98 login; import admin-key.hex into your
signer and log in. If you lose that secret before the first login, re-run
create-admin-wallet (adds a fresh key) and revoke-key the stale one.
The bootstrap latch is MAC-protected and one-way — a live deployment is adopted
as already-bootstrapped, and tampering fails closed (the CLI and the boot path
both refuse a tampered latch; run reset-setup --i-understand to re-open setup).
Expose the admin plane over a Tor onion or an SSH tunnel; never bind it to a
public interface. ADMIN_BIND defaults to loopback, and a non-loopback bind
requires an explicit ADMIN_ALLOW_PUBLIC_BIND=true.
- SSH tunnel:
ssh -L 8081:127.0.0.1:8081 you@server, then reach the plane athttp://127.0.0.1:8081. The loopback Host passes the admin allowlist with no extra config. - Tor onion: point a hidden service at
127.0.0.1:8081and setTOR_ADMIN_ONION=<hash>.onion. That onion host is added to the admin Host allowlist; without it, a request whoseHostheader is the onion is rejected (403), because the allowlist only accepts loopback names plus the one configured onion (anti DNS-rebinding).
The Operator Console (an embedded admin SPA served at /admin on this same
plane) gives you a browser front end for keys, invites, status, and the runtime
config overlay. See CONSOLE.md.
6. Registration policy (optional)
Gates are composable and advertised via /capabilities; returning wallets and
self-hosting bypass them. All default off or open.
- Invite codes —
REGISTRATION_REQUIRE_INVITE=true; mint withsmirk-admin mint-invite --count <n>. - Proof-of-work —
FEATURE_POW=true,POW_REQUIRED=true,ALTCHA_HMAC_KEY. - Restore policy —
WALLET_RESTORE_POLICY=create-only|bounded|unlimited(defaultcreate-only), with optional proof-of-work pricing on restore depth. - Pay-to-register —
REGISTRATION_REQUIRE_PAYMENT=trueagainst a BTCPay-compatible processor (PAYMENT_*); a new wallet settles an invoice, paid to your own wallet, before it can register.
7. Nostr relay (optional)
Run a first-party, locked-down Nostr relay as your users' encrypted-DM inbox
(RELAY_ENABLED=true + RELAY_*). Off by default; write-restricted via a gRPC
admission service, advertised through /capabilities + NIP-05. Full guide:
RELAY.md.
Testing an instance
The @smirk/smoke-tests harness drives the API client end-to-end. Point it at an
instance with SMOKE_BACKEND_URL, and set SMOKE_API_STYLE=namespaced — this
backend serves namespaced /wallet/utxo/* routes, not the legacy flat paths:
SMOKE_BACKEND_URL=https://backend.example.org/api/v1 SMOKE_API_STYLE=namespaced \
npm run check-balance -w @smirk/smoke-tests