Self-Hosted Session Replay on One VPS: a Realistic Setup
Here’s the number that matters before anything else: a self hosted session replay stack — ingest, storage, query layer, player — runs fine on a single 8GB RAM / 4-core VPS for a small SaaS. Not “fine if you squint.” Fine, as in I’ve run this shape of deployment for over a year with the only recurring incident being disk filling up, which is a scheduling problem, not an architecture problem.
That box costs $40–60/month at Hetzner or DigitalOcean as of mid-2026. Everything below assumes that budget and a team of one to five engineers who have better things to do than operate infrastructure.
What a self hosted session replay stack actually contains
Replay platforms in the Highlight/rrweb lineage all decompose into roughly the same five services, and you should know what each one is before you docker compose up a black box:
| Service | Job | Where the pain lives |
|---|---|---|
| Reverse proxy | TLS termination, routes /public ingest |
Cert renewal, body size limits |
| Backend | Auth, session metadata, APIs | Mostly boring |
| Postgres | Users, projects, session index | Backups |
| ClickHouse | Events, logs, traces | RAM appetite |
| Object storage (MinIO or S3) | Compressed replay chunks | Disk growth |
ClickHouse is the one that earns the 8GB requirement. It will happily idle at 1–2GB and spike during merges and heavy queries. Give the stack 4GB total and you’ll meet the OOM killer within a week — I’ve watched it pick off ClickHouse mid-merge and corrupt nothing, which is a credit to ClickHouse, but you don’t want to test that regularly.
LogReplay ships exactly this layout as a single-node Docker deployment, and its stated baseline is the same 8GB / 4-core figure. That’s not vendor generosity; it’s the honest floor for this architecture.
The Docker layout
One compose file, one named volume per stateful service, everything on an internal network except the proxy:
services:
proxy:
image: nginx:stable
ports: ["80:80", "443:443"]
volumes:
- ./nginx.conf:/etc/nginx/conf.d/default.conf:ro
- certs:/etc/letsencrypt
backend:
image: yourvendor/backend
depends_on: [postgres, clickhouse, minio]
postgres:
image: postgres:16
volumes: [pgdata:/var/lib/postgresql/data]
clickhouse:
image: clickhouse/clickhouse-server:24.8
volumes: [chdata:/var/lib/clickhouse]
minio:
image: minio/minio
volumes: [miniodata:/data]
Two opinions, both learned the hard way.
First: pin image tags. latest on ClickHouse across a major version is how you discover schema migrations at 2 a.m.
Second: don’t expose Postgres, ClickHouse, or MinIO ports to the host. Only the proxy gets published ports. Every “my self-hosted analytics got ransomed” story starts with a database port open to the internet with default credentials.
TLS with nginx and certbot
The replay SDK posts from your users’ browsers, so you need a real certificate — no self-signed shortcuts. The standard certbot webroot dance works:
certbot certonly --webroot -w /var/www/certbot \
-d replay.example.com --agree-tos -m ops@example.com
Then the nginx server block. The one line people forget is client_max_body_size — replay payloads are compressed event batches and the nginx default of 1MB will silently 413 your larger sessions:
server {
listen 443 ssl;
server_name replay.example.com;
ssl_certificate /etc/letsencrypt/live/replay.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/replay.example.com/privkey.pem;
client_max_body_size 50m;
location /public {
proxy_pass http://backend:8082;
proxy_read_timeout 60s;
}
}
Serving ingest from your own domain has a side benefit worth naming: ad blockers filter third-party analytics hosts by list, and a first-party /public path on your own subdomain doesn’t match those lists. You’ll capture sessions from the 30–40% of technical audiences running blockers (an estimate, but if your users are developers it’s not a wild one).
Put certbot renew in cron and — this is the part people skip — add a second cron entry that curls your own endpoint weekly and alerts if the cert expires within 14 days. Renewal jobs fail silently; expired certs do not.
Disk growth, with arithmetic
This is the actual operational cost of self-hosting replay, so let’s do it properly.
A compressed rrweb session runs 200KB–2MB depending on page complexity and duration. Call the median 500KB for a typical dashboard-style app. Then:
- 1,000 sessions/month ≈ 0.5GB/month of replay chunks
- 10,000 sessions/month ≈ 5GB/month
- 100,000 sessions/month ≈ 50GB/month
Add ClickHouse event data — logs, network requests, traces — which in my experience lands at roughly the same order of magnitude as the replay chunks. So a 10k-session/month deployment eats about 10GB/month total before retention kicks in.
With 30-day retention that’s a steady-state ~10GB. With 90 days, ~30GB. A 160GB VPS disk handles either without drama. What it will not survive is no retention policy, which is the default failure mode: everything works for eight months, then the disk hits 95% and ingest starts failing, and you discover that ClickHouse behaves badly at 100% disk in ways that are tedious to recover from.
Set retention on day one. Set a disk alert at 80% on day one. These two lines of config are the entire difference between “self-hosting is easy” and the war stories.
Backups: what actually needs saving
Not everything in that stack deserves a backup, and pretending otherwise makes backups so expensive you’ll stop doing them.
Postgres: yes, always. It’s small (session metadata, not sessions) and it’s the part you cannot regenerate. pg_dump nightly, ship it off-box:
docker exec postgres pg_dump -U postgres logreplay | \
gzip | aws s3 cp - s3://backups/pg/$(date +%F).sql.gz
Replay chunks in MinIO: your call. They’re the bulky part, and they expire on schedule anyway. My position: if your retention is 30 days, losing the box loses at most 30 days of replays, and replays are diagnostic evidence, not business records. Most small teams should accept that loss window rather than pay to replicate tens of gigabytes of expiring data. If compliance says otherwise, point MinIO’s bucket replication at real S3 and pay the egress.
ClickHouse: same logic. Rebuildable-ish, expiring, bulky. Skip it until someone gives you a reason in writing.
Test the Postgres restore once. An untested backup is a superstition.
Day-two operations: the actual maintenance calendar
The setup takes an afternoon. The question that decides whether self-hosting was a good idea is what the next twelve months cost you, so here is the honest maintenance load, based on running this shape of stack in anger:
Weekly (automated, you just read alerts): disk usage check, cert expiry check, backup success check. If you wired the alerts from the sections above, this is zero minutes unless something fires.
Monthly (~30 minutes, human): pull updated images for the app services, docker compose up -d, click around the dashboard to confirm nothing broke. Read the release notes first — the one month you skip them is the one with a breaking config change. Postgres and ClickHouse I upgrade far less often, only for security fixes or a feature I actually want, because stateful-service upgrades are where the risk concentrates.
Quarterly (~1 hour): restore the Postgres backup to a scratch container and confirm it works; review retention settings against actual disk trend; reboot the box for kernel updates if your distro doesn’t live-patch.
Add it up and you’re at something like 10–15 hours a year of attention, plus the occasional unscheduled hour when an alert fires. Against a hosted replay bill, that trade is defensible for some teams and not others — the point is to price your time into the comparison honestly instead of comparing $50 of VPS against a SaaS invoice and declaring victory.
Two things make that number blow up, and both are avoidable. Unpinned image tags (covered above) turn routine updates into surprise migrations. And skipping the disk alert converts a 5-minute retention tweak into a multi-hour recovery with the team asking why sessions are missing.
When one box stops being enough
Honest answer: later than the scaling blog posts suggest. The single-node ceiling for this stack is somewhere around a few hundred thousand sessions a month, and the first symptom is ClickHouse query latency during merges, not ingest failures. If you’re a 1–20 engineer SaaS, you will probably change jobs before you hit it.
What actually pushes teams off one box is organizational: someone wants HA, or legal wants storage in a specific region, or you get acquired and inherit a platform team with opinions. Those are fine reasons. “We might need to scale” is not — you can migrate volumes to a bigger VPS in an evening.
If you’re still choosing which stack to run, the deployment shape above applies to all of the serious contenders; the differences are in what’s captured and how the pieces are licensed, which I’ve broken down in a comparison of the open-source session replay options.
One last thing: put the VPS provider’s snapshot feature on a weekly schedule too. It’s a blunt instrument, it’s a couple of dollars a month, and it has bailed me out exactly once — which paid for a decade of it.
See the bug the way your user did
LogReplay captures session replays, console output, network requests, and errors in one timeline — so you stop guessing what happened before the ticket arrived.
Try LogReplay free