Deploy Rybbit on a RamNode VPS
A polished open-source web analytics platform — cookieless, GDPR-friendly, with session replay, funnels, and full data ownership. Replace Plausible or GA without the vendor bill.
At a Glance
| Project | Rybbit (Fastify + Next.js + Postgres 17 + ClickHouse 25.x) |
| License | AGPL-3.0 |
| Recommended Plan | Premium NVMe 4 GB / 2 vCPU |
| OS | Ubuntu 24.04 LTS (x86_64 or ARMv8.2-A+) |
| Storage | 20 GB minimum, 50 GB+ for active session replay |
| Estimated Setup Time | 30–45 minutes |
Why Self-host on RamNode
- Full ownership of visitor data including session replays — no third-party processor
- No row-count or site caps — add as many properties as you want
- Bandwidth included in your VPS plan, not metered per-event
- NVMe-backed Premium plans handle ClickHouse write bursts smoothly
Initial Server Preparation
apt update && apt upgrade -y
apt install -y curl wget git ufw fail2ban htop ca-certificates gnupg lsb-releaseVerify DNS first — provisioning SSL against a domain that does not resolve is a long way to go for an obvious failure.
dig +short tracking.example.com ASwap and Firewall
On a 2 GB plan, add swap to absorb ClickHouse merge spikes. Skip on plans with 4 GB or more.
fallocate -l 2G /swapfile
chmod 600 /swapfile
mkswap /swapfile
swapon /swapfile
echo '/swapfile none swap sw 0 0' >> /etc/fstab
echo 'vm.swappiness=10' >> /etc/sysctl.d/99-rybbit.conf
sysctl --systemufw default deny incoming
ufw default allow outgoing
ufw allow OpenSSH
ufw allow 80/tcp
ufw allow 443/tcp
ufw enableThe Rybbit application ports (3001 backend, 3002 client) should never be exposed publicly.
Install Docker and Compose
install -m 0755 -d /etc/apt/keyrings
curl -fsSL https://download.docker.com/linux/ubuntu/gpg | gpg --dearmor -o /etc/apt/keyrings/docker.gpg
chmod a+r /etc/apt/keyrings/docker.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/etc/apt/keyrings/docker.gpg] \
https://download.docker.com/linux/ubuntu $(lsb_release -cs) stable" \
| tee /etc/apt/sources.list.d/docker.list > /dev/null
apt update
apt install -y docker-ce docker-ce-cli containerd.io docker-buildx-plugin docker-compose-plugin
systemctl enable --now docker
docker run --rm hello-worldInstall Path A — setup.sh with Bundled Caddy
Fastest path. The script writes a minimal .env, generates the auth secret, and brings up the stack with Caddy fronting it. Caddy handles certs automatically.
cd /opt
git clone https://github.com/rybbit-io/rybbit.git
cd rybbit
chmod +x *.sh
./setup.sh tracking.example.com./setup.sh tracking.example.com --mapbox-token pk.eyJ1Ijoi...When the script finishes, browse to https://tracking.example.com/signup, create the admin account, and add your first site.
Install Path B — Manual Compose with Nginx
Use this path if you already run Nginx, want to reuse a wildcard cert, or need IP allowlisting / rate limits Caddy does not handle gracefully.
openssl rand -hex 32DOMAIN_NAME=tracking.example.com
BASE_URL=https://tracking.example.com
BETTER_AUTH_SECRET=replace-with-openssl-rand-hex-32-output
DISABLE_SIGNUP=false
POSTGRES_USER=rybbit
POSTGRES_PASSWORD=use-a-long-random-password-here
POSTGRES_DB=analytics
CLICKHOUSE_DB=analytics
CLICKHOUSE_PASSWORD=another-long-random-password
# Bind to localhost only — without 127.0.0.1 prefix Docker publishes on 0.0.0.0
HOST_BACKEND_PORT=127.0.0.1:3001:3001
HOST_CLIENT_PORT=127.0.0.1:3002:3002chmod 600 .env
docker compose up -d
docker compose ps # postgres + clickhouse should be healthyapt install -y nginx certbot python3-certbot-nginx
certbot certonly --nginx -d tracking.example.com \
--agree-tos --no-eff-email -m you@example.comlimit_req_zone $binary_remote_addr zone=rybbit_track:10m rate=50r/s;
server {
listen 80;
server_name tracking.example.com;
return 301 https://$host$request_uri;
}
server {
listen 443 ssl;
http2 on;
server_name tracking.example.com;
ssl_certificate /etc/letsencrypt/live/tracking.example.com/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/tracking.example.com/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
client_max_body_size 10m;
location /api/ {
limit_req zone=rybbit_track burst=100 nodelay;
proxy_pass http://127.0.0.1:3001;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
location / {
proxy_pass http://127.0.0.1:3002;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade";
}
add_header Strict-Transport-Security "max-age=31536000; includeSubDomains" always;
add_header X-Content-Type-Options "nosniff" always;
add_header Referrer-Policy "strict-origin-when-cross-origin" always;
}ln -s /etc/nginx/sites-available/rybbit.conf /etc/nginx/sites-enabled/
nginx -t && systemctl reload nginxDisable Signups After Creating the Admin
The single most important hardening step. Without it, anyone who finds your tracking domain can use your VPS bandwidth for their own analytics.
DISABLE_SIGNUP=truecd /opt/rybbit
docker compose up -d backendThen drop your tracking snippet into your site's <head>:
<script src="https://tracking.example.com/api/script.js"
data-site-id="1"
defer></script>Performance Tuning for Small Plans
Cap ClickHouse memory. On a 2–4 GB box, ClickHouse will crowd out everything else if uncapped.
<clickhouse>
<max_server_memory_usage>1500000000</max_server_memory_usage>
<max_memory_usage>1000000000</max_memory_usage>
<mark_cache_size>268435456</mark_cache_size>
<uncompressed_cache_size>134217728</uncompressed_cache_size>
</clickhouse>services:
clickhouse:
volumes:
- ./clickhouse-mem.xml:/etc/clickhouse-server/config.d/memory.xml:roTune session replay retention — defaults to 30 days, drop to 14 (or 7) on small VPS plans:
docker exec -it clickhouse clickhouse-client --database analytics
# In the client:
ALTER TABLE session_replay_events
MODIFY TTL toDateTime(timestamp) + INTERVAL 14 DAY;
ALTER TABLE session_replay_metadata
MODIFY TTL start_time + INTERVAL 14 DAY;services:
postgres:
command:
- postgres
- -c
- shared_buffers=256MB
- -c
- effective_cache_size=512MB
- -c
- work_mem=8MB
- -c
- maintenance_work_mem=64MBBackups
Three things matter: Postgres (auth/settings), ClickHouse (events/replays), and the .env (without BETTER_AUTH_SECRET existing sessions cannot be decrypted).
#!/usr/bin/env bash
set -euo pipefail
BACKUP_DIR=/var/backups/rybbit
STAMP=$(date -u +%Y%m%dT%H%M%SZ)
RYBBIT_DIR=/opt/rybbit
mkdir -p "$BACKUP_DIR"
cd "$RYBBIT_DIR"
docker compose exec -T postgres pg_dump -U "${POSTGRES_USER:-rybbit}" -d analytics \
| gzip > "$BACKUP_DIR/postgres-$STAMP.sql.gz"
docker compose exec -T clickhouse clickhouse-client --query \
"BACKUP DATABASE analytics TO Disk('backups', 'analytics-$STAMP.zip')"
docker cp clickhouse:/var/lib/clickhouse/backups/analytics-$STAMP.zip \
"$BACKUP_DIR/clickhouse-$STAMP.zip"
docker compose exec -T clickhouse rm -f /var/lib/clickhouse/backups/analytics-$STAMP.zip
cp "$RYBBIT_DIR/.env" "$BACKUP_DIR/env-$STAMP.bak"
find "$BACKUP_DIR" -type f -name '*.gz' -mtime +30 -delete
find "$BACKUP_DIR" -type f -name '*.zip' -mtime +30 -deletechmod +x /usr/local/bin/rybbit-backup.sh
echo '15 3 * * * root /usr/local/bin/rybbit-backup.sh' > /etc/cron.d/rybbit-backup
apt install -y restic
restic init --repo s3:s3.example.com/rybbit-backupsA backup that lives on the same VPS as the database is a backup that does not exist. Push to RamNode Object Storage, B2, or Wasabi nightly.
Hide the Tracking Endpoint From Ad Blockers
Proxy the tracking script and ingestion through your main site's domain on a non-suspicious path so requests look like first-party traffic.
location /m/ {
rewrite ^/m/(.*)$ /api/$1 break;
proxy_pass https://tracking.example.com;
proxy_set_header Host tracking.example.com;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto https;
proxy_ssl_server_name on;
}<script src="https://www.example.com/m/script.js"
data-site-id="1"
defer></script>The Rybbit script auto-discovers its backend from its own src URL. If you front this with Cloudflare, cache script.js for 5 minutes and bypass cache on /m/track.
Troubleshooting
- Backend crashlooping with auth errors:
BETTER_AUTH_SECRETis shorter than 32 chars or got truncated. Regenerate and restart. - Signup page returns 404/500: Postgres health check has not passed or migrations failed.
docker compose down -vand start over on a fresh box. - Events not appearing: CORS errors mean
BASE_URLdoes not exactly match the dashboard domain (including protocol). 502 errors mean the backend is down or unreachable on127.0.0.1:3001. - ClickHouse OOM: apply the memory cap config; reduce replay retention to 7 days on 2 GB plans.
- Caddy fails to issue cert (Path A): usually a Cloudflare-proxied DNS record. Set to "DNS only", wait two minutes,
docker compose restart caddy. - Container can resolve but cannot pull: Docker MTU mismatch on some RamNode regions — set
"mtu": 1450in/etc/docker/daemon.jsonand restart Docker.
Operational Checklist
DISABLE_SIGNUP=truein.envand reflected in the running backend- UFW shows only 22, 80, 443;
ss -tlnpconfirms app ports bound to127.0.0.1 - Backup script runs cleanly and produces files in
/var/backups/rybbit - Off-site replication has completed at least one full cycle
- Certbot renewal timer is active
