Nginx Load Balancing on VPS: Upstream Configuration, Health Checks, and Sticky Sessions

Nginx Load Balancing on VPS: Upstream Configuration, Health Checks, and Sticky Sessions

Nginx’s upstream module transforms a single VPS into a load balancer that distributes requests across multiple backend servers — whether those backends are multiple processes on the same server, other VPS instances, or a mix. This guide covers upstream configuration, load-balancing algorithms, health checks, sticky sessions, and SSL termination for backend pools.

Basic Upstream Configuration

sudo nano /etc/nginx/sites-available/loadbalancer
upstream app_backends {
    # Round-robin by default — distributes requests evenly
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}

server {
    listen 443 ssl http2;
    server_name app.yourdomain.com;

    ssl_certificate /etc/letsencrypt/live/app.yourdomain.com/fullchain.pem;
    ssl_certificate_key /etc/letsencrypt/live/app.yourdomain.com/privkey.pem;

    location / {
        proxy_pass http://app_backends;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        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_read_timeout 60s;
        proxy_connect_timeout 10s;
    }
}

Load Balancing Algorithms

upstream app_backends {
    # Round-robin (default): requests distributed 1→2→3→1→2→3...
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}

upstream app_backends {
    # Least connections: send to backend with fewest active connections
    # Best for requests with varying processing times
    least_conn;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}

upstream app_backends {
    # IP hash: same client IP always goes to same backend (sticky by IP)
    ip_hash;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}

upstream app_backends {
    # Weighted round-robin: server 1 gets 3x more traffic
    server 10.0.0.1:3000 weight=3;   # Powerful server
    server 10.0.0.2:3000 weight=1;   # Weaker server
}

upstream app_backends {
    # Random: random backend selection (good for very large backend pools)
    random two least_conn;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
    server 10.0.0.4:3000;
}

Server Parameters: Weight, Down, Backup

upstream app_backends {
    server 10.0.0.1:3000 weight=3;         # Gets 3x more requests
    server 10.0.0.2:3000 weight=1;
    server 10.0.0.3:3000 down;             # Temporarily removed from pool
    server 10.0.0.4:3000 backup;           # Only used if all others are down
    server 10.0.0.5:3000 max_fails=3 fail_timeout=30s;  # Health check config
}

Passive Health Checks

Nginx open-source includes passive health checks — it marks a backend as unavailable when requests to it fail:

upstream app_backends {
    server 10.0.0.1:3000 max_fails=3 fail_timeout=30s;
    # max_fails=3:       mark as down after 3 consecutive failures
    # fail_timeout=30s:  keep marked as down for 30 seconds

    server 10.0.0.2:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.3:3000 max_fails=3 fail_timeout=30s;
}

server {
    location / {
        proxy_pass http://app_backends;
        # Tell Nginx what counts as a failure for health check purposes:
        proxy_next_upstream error timeout http_500 http_502 http_503;
        proxy_next_upstream_tries 2;   # Try at most 2 backends before giving up
    }
}

Keepalive Connections to Backends

upstream app_backends {
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;

    # Keep up to 32 idle connections per worker to each backend
    # Eliminates TCP handshake overhead for repeated requests
    keepalive 32;
}

server {
    location / {
        proxy_pass http://app_backends;
        proxy_http_version 1.1;
        proxy_set_header Connection '';   # Required for keepalive to work
    }
}

Sticky Sessions via Cookie

For stateful applications where a user must always reach the same backend, use cookie-based sticky sessions (requires Nginx Plus or the nginx-sticky-module). Alternative: use ip_hash or store all state in Redis so any backend works:

# ip_hash alternative — sticky by client IP:
upstream app_backends {
    ip_hash;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

# Best practice: make backends stateless by storing sessions in Redis
# Then any backend can serve any user — no sticky sessions needed

Load Balancing Multiple Applications

# Different upstream groups for different applications:
upstream api_servers {
    least_conn;
    server 10.0.0.1:8000;
    server 10.0.0.2:8000;
    keepalive 16;
}

upstream web_servers {
    server 10.0.0.3:3000;
    server 10.0.0.4:3000;
    keepalive 16;
}

server {
    listen 443 ssl http2;
    server_name yourdomain.com;

    # Route /api/* to API servers
    location /api/ {
        proxy_pass http://api_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }

    # Route everything else to web servers
    location / {
        proxy_pass http://web_servers;
        proxy_http_version 1.1;
        proxy_set_header Connection '';
        proxy_set_header Host $host;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

SSL Termination with Backend HTTP

The common pattern: Nginx terminates HTTPS externally, backends communicate over HTTP internally (no SSL overhead on the internal network):

upstream app_backends {
    server 10.0.0.1:3000;   # Internal HTTP — no SSL
    server 10.0.0.2:3000;
}

server {
    listen 443 ssl http2;
    server_name app.yourdomain.com;
    # SSL termination happens here at Nginx

    location / {
        proxy_pass http://app_backends;   # Internal HTTP connection
        proxy_set_header X-Forwarded-Proto https;   # Tell backend the original was HTTPS
    }
}

Monitoring Upstream Status

# Enable Nginx stub status module (shows basic connection stats)
location /nginx-status {
    stub_status;
    allow 127.0.0.1;
    deny all;
}

# Check from command line
curl http://127.0.0.1/nginx-status

Load Balancing on a Single Server (Multiple Processes)

Even on a single VPS, upstream load balancing distributes requests across multiple Node.js or Python processes to use all CPU cores:

upstream node_app {
    server 127.0.0.1:3001;   # Node process 1
    server 127.0.0.1:3002;   # Node process 2
    server 127.0.0.1:3003;   # Node process 3
    server 127.0.0.1:3004;   # Node process 4 (one per CPU core)
    keepalive 32;
}

# PM2 starts multiple Node processes:
# pm2 start app.js -i 4   (4 = number of CPU cores)

Getting Started

Nginx load balancing scales from a single VPS with multiple application processes to a multi-server pool. Start with multiple processes on one Ubuntu VPS at VPS.DO — a 4 vCPU VPS running 4 Node.js processes with Nginx upstream delivers significantly better throughput than a single process. When the single VPS reaches its limits, add a second VPS as a backend with minimal configuration changes.

Conclusion

Nginx upstream load balancing provides round-robin, least-connections, and IP-hash algorithms; passive health checks that automatically remove failed backends; keepalive connection pooling; and SSL termination. For stateful applications, the practical answer is usually to make backends stateless (sessions in Redis) rather than implementing sticky sessions — this keeps the Nginx configuration simple and allows any backend to serve any request.

Fast • Reliable • Affordable VPS - DO It Now!

Get top VPS hosting with VPS.DO’s fast, low-cost plans. Try risk-free with our 7-day no-questions-asked refund and start today!