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.