Paying \$30 to \$100 every month for SaaS uptime monitoring (like Datadog, BetterStack, or Pingdom) is an unnecessary expense for indie hackers, small engineering teams, and homelabs.

Uptime Kuma has become the standard self-hosted alternative. It is fast, lightweight, and supports everything from HTTP status pings and SSL certificate tracking to DNS queries and Docker container healthchecks.

However, deploying it in production has one notorious pitfall: if you put it behind Nginx without specific WebSocket upgrade headers, the UI enters an infinite reconnection loop. Below is the production setup, the exact error to watch out for, and a safe automated SQLite backup script.

⚠️ The #1 Pitfall: The WebSocket Handshake Failure

If you proxy Uptime Kuma with a standard HTTP proxy_pass block, the browser console will continuously spit out this error:

WebSocket connection to 'wss://status.example.com/socket.io/?EIO=4&transport=websocket' failed: Error during WebSocket handshake: Unexpected response code: 400

Root Cause: Uptime Kuma uses Socket.io. When Nginx fails to forward the Upgrade and Connection HTTP headers, the server rejects the socket negotiation, forcing the UI to fall back to aggressive long-polling that spikes server CPU.

Step 1: The Hardened Docker Compose Stack

Create a working directory on your VPS:

mkdir -p /opt/uptime-kuma && cd /opt/uptime-kuma

Create docker-compose.yml. Notice that we bind the port to 127.0.0.1:3001 rather than 0.0.0.0 so the raw HTTP port is never exposed directly to the public web:

version: '3.8'

services:
  uptime-kuma:
    image: louislam/uptime-kuma:1
    container_name: uptime-kuma
    restart: unless-stopped
    security_opt:
      - no-new-privileges:true
    volumes:
      - ./data:/app/data
    ports:
      # Localhost only: Nginx terminates public TLS
      - "127.0.0.1:3001:3001"
    environment:
      - UPTIME_KUMA_PORT=3001
      - NODE_ENV=production
    healthcheck:
      test: ["CMD-SHELL", "node extra/healthcheck.js"]
      interval: 30s
      timeout: 10s
      retries: 3
      start_period: 20s
    deploy:
      resources:
        limits:
          memory: 512M
          cpus: '0.75'

Start the container:

docker compose up -d

Step 2: Nginx Reverse Proxy with WebSocket Headers

To solve the WebSocket handshake error, ensure your Nginx virtual host explicitly sets proxy_http_version 1.1 and forwards the upgrade headers.

Save this configuration at /etc/nginx/sites-available/status.example.com:

server {
    listen 80;
    server_name status.example.com;
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl http2;
    server_name status.example.com;

    # SSL managed via Let's Encrypt / Certbot
    ssl_certificate /etc/letsencrypt/live/status.example.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/status.example.com/privkey.pem;

    # Security Headers
    add_header X-Frame-Options "SAMEORIGIN" always;
    add_header X-Content-Type-Options "nosniff" always;
    add_header Referrer-Policy "strict-origin-when-cross-origin" always;

    location / {
        proxy_pass http://127.0.0.1:3001;
        proxy_http_version 1.1;

        # CRITICAL HEADERS TO PREVENT WEBSOCKET 400 ERRORS
        proxy_set_header Upgrade $http_upgrade;
        proxy_set_header Connection "upgrade";

        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;

        # Keep alive for real-time heartbeats
        proxy_read_timeout 86400s;
        proxy_send_timeout 86400s;
    }
}

Enable the site and reload Nginx:

sudo ln -s /etc/nginx/sites-available/status.example.com /etc/nginx/sites-enabled/
sudo nginx -t && sudo systemctl reload nginx

Step 3: Safe Online SQLite Backup (Preventing WAL Corruption)

Uptime Kuma stores its data in /opt/uptime-kuma/data/kuma.db.

Because SQLite operates in WAL (Write-Ahead Logging) mode, simply running cp kuma.db backup.db while the container is actively writing can result in a corrupted, incomplete file.

Instead, use SQLite's native .backup command to create a safe online snapshot:

#!/usr/bin/env bash
set -euo pipefail

BACKUP_DIR="/opt/backups/uptime-kuma"
TIMESTAMP=$(date +"%Y%m%d_%H%M%S")
mkdir -p "$BACKUP_DIR"

# Perform atomic online snapshot while container is running
sqlite3 /opt/uptime-kuma/data/kuma.db ".backup '$BACKUP_DIR/kuma_$TIMESTAMP.db'"

# Automatically purge backups older than 14 days
find "$BACKUP_DIR" -name "kuma_*.db" -type f -mtime +14 -delete

echo "[✓] Backup completed: $BACKUP_DIR/kuma_$TIMESTAMP.db"

Make the script executable and add it to root's crontab (crontab -e):

0 3 * * * /opt/uptime-kuma/backup.sh >> /var/log/uptime-backup.log 2>&1

Production Verification Checklist

// POST-DEPLOYMENT VERIFICATION
  • [✓] WebSocket connection active: Open DevTools → Network → WS. You should see a status 101 Switching Protocols with regular frame exchanges.
  • [✓] Port 3001 closed publicly: Verify that http://your-server-ip:3001 cannot be reached from an external network.
  • [✓] Notification retry set to 2: In Uptime Kuma settings, set Retries to 2 with a 20s interval to prevent transient network hiccups from paging you at 3 AM.
  • [✓] Isolated server host: Ensure Uptime Kuma runs on a different machine or cloud region than the infrastructure it monitors.