Skip to content
rifleDB
Docs: Operations

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/main.js (built with pnpm -F @rifledb/server build; pnpm -F @rifledb/server dev runs the source with tsx watch) serves REST, server-sent events and Socket.IO on one port with the folders of server/rifle.config.tsx.

  • Health: GET /v1/_/health → {ok, epoch, store, healthy, uptimeS, version}. Always 200 while the process answers; healthy is false while Redis is unreachable. The Docker image's HEALTHCHECK uses it.
  • Shutdown: SIGTERM/SIGINT stop accepting connections, end event streams, disconnect sockets (their onDisconnect ops 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 example EADDRINUSE) 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.

VariableDefaultMeaning
RIFLE_PORT4000Listening port
RIFLE_STOREredis if REDIS_URL is set, else memorymemory keeps everything in the process (gone on restart; a warning is logged)
REDIS_URL—redis:// or rediss:// URL, e.g. redis://localhost:6380 (pnpm redis:dev)
RIFLE_KEY_PREFIXrifle:v1:Prefix of every Redis key ([A-Za-z0-9:_.-]{1,64})
RIFLE_PUBLIC_ORIGINhttp://localhost:<port>The origin clients reach the server at; part of every socket auth proof
RIFLE_PUBLIC_HOSTSthe host of the public originComma list of Host values signed requests may carry
RIFLE_ALLOWED_ORIGINShttp://localhost:3000,http://localhost:3001,http://localhost:5173Browser origins allowed for sockets and CORS (set it explicitly empty for none)
RIFLE_TRUST_PROXY—Comma list of proxy CIDRs whose X-Forwarded-For is believed (below)
RIFLE_LOG_LEVELinfodebug, info, warn, error, silent
RIFLE_ADMIN_SETUPonoff disables the admin setup token and admin.bootstrap
RIFLE_WEBAUTHNrpID localhost for ports 3000/3001/5173JSON [{"rpID", "rpName", "origins": [...]}]; each origin must be on its rpID and appear once
RIFLE_MAX_CONNECTIONS_PER_IP64Open 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:

  1. RIFLE_TRUST_PROXY must name the proxy's network. Rate limits are per client address; the server takes the client address from X-Forwarded-For only 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 when RIFLE_PUBLIC_ORIGIN is https and the variable is empty. Trust only the proxy's own network: anything inside it can claim any client address.
  2. The proxy must pass Host unchanged. HTTP signatures cover the Host header, and it must be one of RIFLE_PUBLIC_HOSTS; X-Forwarded-Host is ignored. nginx-proxy does this by default, and it also proxies WebSockets and leaves event streams unbuffered (the server sends X-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_TRUST_PROXY is wrong.

Rate limits and caps

WhatLimit
REST requests300/s per client address (429 + Retry-After)
Socket messages200/s per socket, 2,000/s per address
Socket auth3/s per session, 30/s per address; ≤ 16 proofs per message
Registrations10 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.opsPerSec)the folder's own rate, shared by all writers and transports (actions on it count)
Socket connections64 per address (RIFLE_MAX_CONNECTIONS_PER_IP)
User folders (users/<uid>, /profile, /secret)1,000 keys per folder, values ≤ 16 KB, 1 MB for the three together (<Users limits>)
Admin bootstrap attempts5/min per address
Passkey ceremonies30/min per device key (or socket), 120/min per address; recovery 10/min per address
Device link codes10/min per user
Actionstheir own rate (e.g. demo drop: 4/s per visitor and 40/s per address; playground reset: 1/s)
Subscriptions512 per socket; event streams 64 per address
Payloads64 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_RATE 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 MULTI per tick) before acknowledging; fast folders acknowledge from memory and snapshot every 2 s when persist is on.
  • Run Redis with appendonly yes and appendfsync 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_UNAVAILABLE (dirty data is retried with backoff), fast folders keep working, and /v1/_/health reports healthy: false.
  • The layout is in architecture.md §3.1. Do not set a maxmemory eviction policy: evicted keys are lost data.

Backups

All state is in Redis. Back up its data directory (dump.rdb 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.md

To restore, stop the rifleDB server and Redis, and bring the backup back. With AOF on, Redis loads the AOF rather than dump.rdb, 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_ADMIN_SETUP=off to disable bootstrap entirely. RIFLE_LOG_LEVEL=silent 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/metrics while someone watches it (admin role; no step-up needed):

  • system/metrics/now: one array of 27 numbers in the order of METRICS_LAYOUT (@rifledb/protocol, 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.
  • system/metrics/top (busiest folders) and system/metrics/ops (ops by kind).
  • Every minute a record goes to the Redis stream metrics:1m (7 days kept); the action metrics.history {range: '5m' | '1h' | '24h' | '7d'} on system/metrics returns downsampled columns.
  • The op stream: subscribers of system/ops receive evt op messages [t, op, path, ok, ms, writerKind], at most 200 per second, only while someone subscribes; secret paths are redacted to <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/rifle.config.tsx ships playground (public, publicWrite), playground/live (fast, not persisted) and playground/live/presence (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/slider, live/presence/<slot>), 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/rifle.config.tsx (and update server/test/config.test.ts and server/test/playground.test.ts, which pin it).