Web Analytics
    GDPR-friendly

    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

    ProjectRybbit (Fastify + Next.js + Postgres 17 + ClickHouse 25.x)
    LicenseAGPL-3.0
    Recommended PlanPremium NVMe 4 GB / 2 vCPU
    OSUbuntu 24.04 LTS (x86_64 or ARMv8.2-A+)
    Storage20 GB minimum, 50 GB+ for active session replay
    Estimated Setup Time30–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
    1

    Initial Server Preparation

    Patch + utilities
    apt update && apt upgrade -y
    apt install -y curl wget git ufw fail2ban htop ca-certificates gnupg lsb-release

    Verify DNS first — provisioning SSL against a domain that does not resolve is a long way to go for an obvious failure.

    Verify DNS
    dig +short tracking.example.com A
    2

    Swap and Firewall

    On a 2 GB plan, add swap to absorb ClickHouse merge spikes. Skip on plans with 4 GB or more.

    Add 2 GB swap (small plans only)
    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 --system
    UFW — only 22, 80, 443
    ufw default deny incoming
    ufw default allow outgoing
    ufw allow OpenSSH
    ufw allow 80/tcp
    ufw allow 443/tcp
    ufw enable

    The Rybbit application ports (3001 backend, 3002 client) should never be exposed publicly.

    3

    Install Docker and Compose

    Official Docker repo (not the distro package)
    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-world
    4

    Install 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.

    Clone + run
    cd /opt
    git clone https://github.com/rybbit-io/rybbit.git
    cd rybbit
    chmod +x *.sh
    ./setup.sh tracking.example.com
    With Mapbox globe
    ./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.

    5

    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.

    Generate auth secret (must be ≥32 chars)
    openssl rand -hex 32
    /opt/rybbit/.env
    DOMAIN_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:3002
    Start stack without bundled Caddy
    chmod 600 .env
    docker compose up -d
    docker compose ps   # postgres + clickhouse should be healthy
    Issue cert
    apt install -y nginx certbot python3-certbot-nginx
    certbot certonly --nginx -d tracking.example.com \
      --agree-tos --no-eff-email -m you@example.com
    /etc/nginx/sites-available/rybbit.conf
    limit_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;
    }
    Enable + reload
    ln -s /etc/nginx/sites-available/rybbit.conf /etc/nginx/sites-enabled/
    nginx -t && systemctl reload nginx
    6

    Disable 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.

    Edit /opt/rybbit/.env
    DISABLE_SIGNUP=true
    Restart backend
    cd /opt/rybbit
    docker compose up -d backend

    Then drop your tracking snippet into your site's <head>:

    Tracking snippet
    <script src="https://tracking.example.com/api/script.js"
            data-site-id="1"
            defer></script>
    7

    Performance Tuning for Small Plans

    Cap ClickHouse memory. On a 2–4 GB box, ClickHouse will crowd out everything else if uncapped.

    /opt/rybbit/clickhouse-mem.xml
    <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>
    Mount in docker-compose.override.yml
    services:
      clickhouse:
        volumes:
          - ./clickhouse-mem.xml:/etc/clickhouse-server/config.d/memory.xml:ro

    Tune session replay retention — defaults to 30 days, drop to 14 (or 7) on small VPS plans:

    ClickHouse TTL
    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;
    Postgres tuning for 4 GB host
    services:
      postgres:
        command:
          - postgres
          - -c
          - shared_buffers=256MB
          - -c
          - effective_cache_size=512MB
          - -c
          - work_mem=8MB
          - -c
          - maintenance_work_mem=64MB
    8

    Backups

    Three things matter: Postgres (auth/settings), ClickHouse (events/replays), and the .env (without BETTER_AUTH_SECRET existing sessions cannot be decrypted).

    /usr/local/bin/rybbit-backup.sh
    #!/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 -delete
    Schedule + offsite
    chmod +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-backups

    A 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.

    9

    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.

    In your main site's nginx config
    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;
    }
    Updated tracking snippet
    <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.

    10

    Troubleshooting

    • Backend crashlooping with auth errors: BETTER_AUTH_SECRET is 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 -v and start over on a fresh box.
    • Events not appearing: CORS errors mean BASE_URL does not exactly match the dashboard domain (including protocol). 502 errors mean the backend is down or unreachable on 127.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": 1450 in /etc/docker/daemon.json and restart Docker.

    Operational Checklist

    • DISABLE_SIGNUP=true in .env and reflected in the running backend
    • UFW shows only 22, 80, 443; ss -tlnp confirms app ports bound to 127.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