Self-hosting

Three Node processes and three pieces of infrastructure. Each scales independently, and the ingest path is deliberately the cheapest thing in the system.

What you need

DependencyUsed forNotes
PostgreSQL 15+Sessions, chunk index, request links, spansThe index, never the recordings themselves
Redis 7+BullMQ queuesMust run with maxmemory-policy noeviction
S3-compatible storagerrweb chunks and raw OTLP bodiesMinIO, S3, R2 — anything with the API

The processes

  • api — authenticates, stores the raw body, enqueues, returns 202. It parses nothing, so it stays cheap under load and never inflates hostile gzip on an HTTP connection. Stateless; scale horizontally.
  • worker — decompresses, validates, normalizes OTLP, writes rows. The expensive work lives here. Scale by process count or with WORKER_CONCURRENCY.
  • web — the recordings list and the viewer. Holds the secret key server-side; the browser never receives it.

Configuration

DATABASE_URL=postgresql://…
REDIS_URL=redis://…
S3_ENDPOINT=https://…
S3_BUCKET=syncline
S3_ACCESS_KEY_ID=
S3_SECRET_ACCESS_KEY=
S3_FORCE_PATH_STYLE=true   # MinIO yes, most clouds no
API_PORT=4000
WORKER_CONCURRENCY=4

Every variable is validated at startup. A missing one stops the process with a message naming it, rather than surfacing as a connection error on the first ingest an hour later.

Sizing

rrweb chunks dominate storage — expect a few hundred KB per minute of an active session, compressed. Postgres holds only the index: rows per chunk, per request and per span, which stays small until span volume grows. Spans are written through a SpanStore port precisely so that a move to ClickHouse is a new implementation and a config flag rather than a rewrite.

Operating notes

  • GET /v1/health returns 503 and names the failing dependency. Point your load balancer at it.
  • Ingest stores the body before enqueuing, so a Redis outage loses an upload the client already paid for. Health checks Redis for exactly that reason.
  • Jobs are idempotent. Retries and redeliveries upsert; a body that will never be valid fails once instead of three times.
  • Chunks are immutable once written, and served with a long cache lifetime.
  • Ports 5442 and 6399 in the bundled compose file are deliberate: a machine already running Postgres or Redis owns the standard ports, and a host connection can silently reach the wrong server.

Running it

One Dockerfile with three targets — api, worker, web — plus a migrate target that runs to completion before any of them start. docker-compose.prod.yml wires the four together:

# fill this in first — nothing has a working default
cp .env.production.example .env.production

docker compose -f docker-compose.prod.yml \
  --env-file .env.production up -d --build

Point DATABASE_URL, REDIS_URL and S3_ENDPOINT at managed services and that is the whole deployment. For a single box with none of those, the bundled profile brings up Postgres, Redis and MinIO alongside.

Migrations are never run at container start. A dozen replicas booting together would race each other through the same migration, and one failing would take the rollout down rather than one job — so the migrate service runs first and the rest wait for it.

Ingest limits

Every other bound in the ingest path limits a single request — the body cap, the chunk ceiling, the per-session sequence limit. None of them limits how many requests arrive, and that matters here more than it might elsewhere: the public key is designed to ship in a browser bundle, and the origin allowlist is enforced by browsers rather than by the server. Anyone who reads a bundle can post as that project from a script.

So each project has two ceilings, counted in Redis: INGEST_REQUESTS_PER_MINUTE stops a flood happening now, and INGEST_BYTES_PER_DAY stops a slow drip filling the object store over a week — the one that arrives as a bill rather than an outage. Past either, ingest answers 429 with Retry-After and a body naming which ceiling was hit and when it resets. The SDK reads that header and stops sending until it passes.

The defaults are generous on purpose — a limit a real site trips is a limit somebody disables. Set either to 0 to turn it off, which is reasonable only when the network is private and the only client is your own application.

Retention

Recordings are the bulkiest thing Syncline stores, and little about a six-month-old session is worth what it costs to keep. Set RETENTION_DAYS on the worker and it sweeps hourly, deleting every session older than that along with its chunks in the object store, the spans no surviving recording still points at, and the raw OTLP bodies from those days.

It is 0 out of the box, which keeps everything forever. An upgrade that quietly started destroying history would be the worst possible way to find out this feature exists, so switching it on is always a decision somebody made. There is no upper bound either — set it to 3650 if ten years is the policy.

This deletes recordings permanently. The objects are removed from the bucket, not moved to a trash prefix, and there is no undo. Check the number before setting it, and turn on bucket versioning first if a safety net matters.

RETENTION_INTERVAL_MINUTES (default 60) sets how often the sweep runs. Each pass works in batches of 200 sessions, so a first sweep against a year of backlog takes several passes rather than one enormous delete holding locks while ingest is still writing.

Roles

Membership decides what somebody can see; their role decides what they can change. Members read. Admins run projects — settings, key rotation, search keys, invitations. Owners additionally delete. Every mutation checks the role on the server; hidden buttons are a courtesy, not the boundary.

Every one of those mutations is recorded in the audit log, which owners and admins read from the sidebar: who changed a project, rotated a key, dropped a search key, invited or removed or re-roled somebody. Reads are not recorded — a log of who watched which recording is a surveillance feature, and it would bury the entries that matter. Entries outlive the projects and accounts they name, and the recording retention window does not touch them.

Deleting a project

An owner can delete a project from its settings page, after typing its name. The project leaves the dashboard at once and ingest refuses its keys within a minute; the recordings themselves — rows, chunks, spans and raw OTLP bodies — are erased by the next sweep, which runs whether or not RETENTION_DAYS is set. A deletion is an instruction, not a retention policy.

The delay is deliberate. A project with a year of recordings is hundreds of thousands of rows and as many objects, and deleting them inside the request would time out partway through — leaving the rows gone and their blobs stranded under keys nothing can reconstruct.

What is still missing. No SSO, and no way to export a project before deleting it. Put it behind a proxy that terminates TLS: the session cookies are secure, so the browser will not send them over plain HTTP.