Operating a rifleDB server
Configuration, observability, backups and the knobs that matter in production. The production deployment itself
(Docker, nginx-proxy, step by step) is in deploy/README.md.
Process
node dist/ (built with pnpm -F @rifledb/; pnpm -F @rifledb/ runs the source with
tsx watch) serves REST, server-sent events and Socket.IO on one port with the folders of server/.
- Health:
GET /→v1/ _ / health {ok,. Alwaysepoch, store, healthy, uptimeS, version} 200while the process answers;healthyisfalsewhile Redis is unreachable. The Docker image'sHEALTHCHECKuses it. - Shutdown:
SIGTERM/SIGINTstop accepting connections, end event streams, disconnect sockets (theironDisconnectops run), wait up to 5 s for requests in flight, flush pending writes and exit 0. A second signal exits at once (code 1); after 15 s the process gives up (code 1). - Exit codes:
2= invalid environment (every problem is listed on stderr),1= startup failure (for exampleEADDRINUSE) or a crash. - Smoke test against any running server (registers a throwaway user, checks REST, ETags,
If-Match, SSE):pnpm -F @rifledb/.server smoke -- --url https:/ / rifle-api. example. com
Environment
All variables are optional; with none the server runs on port 4000 with an in-memory store. Invalid values stop it with exit code 2.
| Variable | Default | Meaning |
|---|---|---|
RIFLE_ | 4000 | Listening port |
RIFLE_ | redis if REDIS_ is set, else memory | memory keeps everything in the process (gone on restart; a warning is logged) |
REDIS_ | — | redis:/ or rediss:/ URL, e.g. redis:/ (pnpm redis:dev) |
RIFLE_ | rifle:v1: | Prefix of every Redis key ([A-Za-z0-9:_) |
RIFLE_ | http:/ | The origin clients reach the server at; part of every socket auth proof |
RIFLE_ | the host of the public origin | Comma list of Host values signed requests may carry |
RIFLE_ | http:/ | Browser origins allowed for sockets and CORS (set it explicitly empty for none) |
RIFLE_ | — | Comma list of proxy CIDRs whose X-Forwarded-For is believed (below) |
RIFLE_ | info | debug, info, warn, error, silent |
RIFLE_ | on | off disables the admin setup token and admin. |
RIFLE_ | rpID localhost for ports 3000/3001/5173 | JSON [{"rpID",; each origin must be on its rpID and appear once |
RIFLE_ | 64 | Open sockets one client address (IPv6: /64) may hold at once; further handshakes are refused |
The production values are in deploy/docker-compose.yml and
deploy/.env.example.
Reverse proxy, Host and client addresses
Behind nginx-proxy (or any reverse proxy) two settings matter:
RIFLE_must name the proxy's network. Rate limits are per client address; the server takes the client address fromTRUST_ PROXY X-Forwarded-Foronly when the connection comes from a trusted proxy. Without it every visitor shares the proxy's address and therefore one bucket: the demo's 40 drops per second per address, the 300 REST requests per second per address, the public-write buckets, the registration limit, the 64 sockets per address. The server warns at startup whenRIFLE_is https and the variable is empty. Trust only the proxy's own network: anything inside it can claim any client address.PUBLIC_ ORIGIN - The proxy must pass
Hostunchanged. HTTP signatures cover theHostheader, and it must be one ofRIFLE_;PUBLIC_ HOSTS X-Forwarded-Hostis ignored. nginx-proxy does this by default, and it also proxies WebSockets and leaves event streams unbuffered (the server sendsX-Accel-Buffering: no).
A quick check after deploying: the admin's Connections page shows each socket's (masked) address. If every row shows
the proxy's address, RIFLE_ is wrong.
Rate limits and caps
| What | Limit |
|---|---|
| REST requests | 300/s per client address (429 + Retry-After) |
| Socket messages | 200/s per socket, 2,000/s per address |
| Socket auth | 3/s per session, 30/s per address; ≤ 16 proofs per message |
| Registrations | 10 per address per hour |
Public writes (publicWrite folders) | 30/s per session, 200/s per address; fast folders 1,000/s per address (presence of 50 visitors behind one NAT at 20 Hz) |
Folder writes (limits.) | the folder's own rate, shared by all writers and transports (actions on it count) |
| Socket connections | 64 per address (RIFLE_) |
User folders (users/, /, /) | 1,000 keys per folder, values ≤ 16 KB, 1 MB for the three together (<Users limits>) |
| Admin bootstrap attempts | 5/min per address |
| Passkey ceremonies | 30/min per device key (or socket), 120/min per address; recovery 10/min per address |
| Device link codes | 10/min per user |
| Actions | their own rate (e.g. demo drop: 4/s per visitor and 40/s per address; playground reset: 1/s) |
| Subscriptions | 512 per socket; event streams 64 per address |
| Payloads | 64 KB per message or request body; values ≤ 64 KB (fast folders ≤ 2 KB); ≤ 10,000 keys per folder |
Per-address limits use the IPv4 address or the IPv6 /64. Rate refusals are E_ with retryAfter and a scope
(Protocol). The SDK holds its queued writes and retries them in order after a connection-wide
refusal; after a public-write refusal it holds back only that folder and keeps just the newest unsent write per
fast-folder key (presence degrades to fewer updates, never to a queue); a refused action call or folder-rate refusal
rejects to the caller.
The public-write budget for fast folders is sized for the demo's presence: 20 Hz per visitor, so 1,000/s serve 50 visitors behind one address (a school or office NAT, a carrier-grade NAT) at full rate. Above that the updates of everyone behind the address slow down evenly; drops (an action with its own 40/s per address) are unaffected.
Durability and Redis
- Normal folders commit in groups (one Redis
MULTIper tick) before acknowledging; fast folders acknowledge from memory and snapshot every 2 s whenpersistis on. - Run Redis with
appendonly yesandappendfsync everysec(the dev script and the production compose file do): a Redis crash may lose up to about one second of acknowledged writes; a crash of the Node process loses none. - While Redis is unreachable, writes to normal folders are refused with
E_(dirty data is retried with backoff), fast folders keep working, andUNAVAILABLE /reportsv1/ _ / health healthy: false. - The layout is in architecture.md §3.1. Do not
set a
maxmemoryeviction policy: evicted keys are lost data.
Backups
All state is in Redis. Back up its data directory (dump. and appendonlydir/), for example nightly:
redis-cli -h <host> -p <port> BGSAVE # writes a fresh dump.rdb in the background
# then copy dump.rdb off the machine; for the production stack see deploy/README.mdTo restore, stop the rifleDB server and Redis, and bring the backup back. With AOF on, Redis loads the AOF rather than
dump., so move appendonlydir/ aside and load the dump with AOF off first; the exact commands are in
deploy/README.md. The server recovers the global sequence from meta and starts a
new epoch, so clients resynchronise.
Admin bootstrap
While no user has the admin role, the server prints a framed banner with a one-time setup token on stderr at every
start (it is the only secret it ever logs). The token is 32 random bytes, stored only as a hash, valid 60 minutes and
single use; a restart replaces it. Enter it on the admin app's sign-in page ("I have a setup token"); the app then asks
the browser for a passkey. Once an admin exists no token is printed; set RIFLE_ to disable bootstrap
entirely. RIFLE_ hides the banner too. Details: Access control.
Metrics
With metrics on (the standalone server always has them) the server samples once a second and publishes into the fast
folder system/ while someone watches it (admin role; no step-up needed):
system/: one array of 27 numbers in the order ofmetrics/ now METRICS_(LAYOUT @rifledb/, version in index 0): ops/s and errors, acked writes and messages, ack and persist latency p50/p95/p99, commits, folder loads, connections, subscriptions, rooms, loaded folders, heap and RSS, event-loop utilization and delay p99, Redis round trip (every 5 s), bytes in/out.protocol system/(busiest folders) andmetrics/ top system/(ops by kind).metrics/ ops - Every minute a record goes to the Redis stream
metrics:1m(7 days kept); the actionmetrics.onhistory {range: '5m' | '1h' | '24h' | '7d'} system/returns downsampled columns.metrics - The op stream: subscribers of
system/receiveops evt opmessages[t,, at most 200 per second, only while someone subscribes; secret paths are redacted toop, path, ok, ms, writerKind] <folder>/.*
The admin app shows all of this on Overview, Metrics and Ops. For load testing: pnpm bench -- --help; for
the performance suite and regression comparisons: Benchmarks.
Logs and audit
Logs are one JSON object per line (info/debug on stdout, warn/error on stderr). They carry identifiers,
counts, durations and error codes, never stored values or invite fragments. Admin and security events (bootstrap,
role and key changes, passkey registrations and resets, kicks, wipes, folder changes) go to the Redis stream audit
(about 100,000 entries kept), readable in the admin app.
The playground folders
server/ ships playground (public, publicWrite), playground/ (fast, not persisted) and
playground/ (fast, publicWrite: 'own': each tab writes only its own dot): the sandbox of the Vite
example and of the admin explorer's "Try the playground" link. They stay in production on purpose: they accept only
their known keys (counter, toggle, todos with ≤ 50 short items, live/, live/), at most
16, 8 and 200 keys, values ≤ 16 KB, and public writes are rate-limited. Anyone can change
them, so do not show their text on a public page without moderation. To remove them, delete the <Folder path='playground'> block from server/ (and update server/ and
server/, which pin it).