Blog/How to Set Up Nginx Load Balancing with Failover (upstream, least_conn, and Health Checks)

NginxLoad BalancingHigh AvailabilityDevOpsReliability··greencrew

How to Set Up Nginx Load Balancing with Failover (upstream, least_conn, and Health Checks)

To load balance with Nginx, list your backend servers in an upstream block, choose a method such as least_conn, set max_fails=3 fail_timeout=30s for passive health checks, and send traffic with proxy_pass http://backend. Covers failover with backup servers, sticky sessions, retries, and monitoring each backend.

Short answer: define an upstream backend { least_conn; server 10.0.0.11:3000 max_fails=3 fail_timeout=30s; server 10.0.0.12:3000 max_fails=3 fail_timeout=30s; } block and point proxy_pass http://backend; at it. Add proxy_next_upstream error timeout http_502 http_503; so a failed request is retried on the next server. Nginx open source spreads traffic across healthy backends and stops sending requests to a server that keeps failing. It can't actively probe backends, though; that's an NGINX Plus feature. So you also need external monitoring on each backend to know when one is down. This guide covers every piece, with copy-paste configs.

What Is Nginx Load Balancing?

Load balancing spreads incoming requests across several backend servers, so no single server is overloaded and one failure doesn't take your site down. Nginx load balances HTTP, HTTPS, gRPC, and, with the stream module, raw TCP and UDP traffic. It does this with the ngx_http_upstream_module, which ships with every Nginx build.

A typical layout:

                 ┌──────────────┐
 clients ──────► │    Nginx     │
                 │ (TLS, LB)    │
                 └──┬───┬───┬───┘
                    │   │   │
           ┌────────┘   │   └────────┐
           ▼            ▼            ▼
      app-1:3000   app-2:3000   app-3:3000 (backup)

Step 1: Define the Upstream Group

Create /etc/nginx/conf.d/upstream.conf:

upstream backend {
    least_conn;
    zone backend 64k;

    server 10.0.0.11:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.12:3000 max_fails=3 fail_timeout=30s;
    server 10.0.0.13:3000 backup;

    keepalive 32;
}
LineWhat it does
least_connSends each request to the server with the fewest active connections
zone backend 64kShares backend state (failures, connection counts) across all worker processes
max_fails=3 fail_timeout=30sAfter 3 failed attempts within 30 s, mark the server unavailable for 30 s
backupOnly receives traffic when all primary servers are unavailable
keepalive 32Keeps up to 32 idle connections open per worker to reuse

Step 2: Send Traffic to the Upstream

server {
    listen 80;
    server_name example.com;

    location / {
        proxy_pass http://backend;
        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_connect_timeout 3s;
        proxy_read_timeout    30s;

        proxy_next_upstream error timeout http_502 http_503 http_504;
        proxy_next_upstream_tries 2;
        proxy_next_upstream_timeout 10s;
    }
}
sudo nginx -t && sudo systemctl reload nginx

If you're new to the proxy headers above, our guide on setting up Nginx as a reverse proxy explains each one.

Step 3: Choose a Load Balancing Method

MethodDirectiveHow it picks a serverBest for
Round robin(default, no directive)Each server in turn, respecting weightIdentical, stateless servers
Least connectionsleast_conn;Fewest active connectionsRequests with very different durations
IP haship_hash;Hash of the client IPv4 /24 or full IPv6Simple sticky sessions
Generic hashhash $request_uri consistent;Hash of any variableCache affinity, sharding
Randomrandom two least_conn;Picks two at random, sends to the less loadedMany Nginx instances balancing the same pool
Least timeleast_time header;Lowest response timeNGINX Plus only

Weighted Servers

When backends have different capacity, give the larger one more traffic:

upstream backend {
    server 10.0.0.11:3000 weight=3;   # gets ~3x the requests
    server 10.0.0.12:3000 weight=1;
}

Sticky Sessions

If your app keeps sessions in local memory, a user must keep hitting the same server. Use ip_hash, or hash on a session cookie for more even spread:

upstream backend {
    hash $cookie_sessionid consistent;
    server 10.0.0.11:3000;
    server 10.0.0.12:3000;
}

The better long-term fix is to move sessions into Redis or a database, so any server can handle any request and failover is seamless.

How Failover Works: Passive Health Checks

Nginx open source uses passive health checks. It doesn't probe backends. It watches real requests, and when a request to a server fails (connection refused, timeout, or a status listed in proxy_next_upstream), it counts a failure against that server.

SettingDefaultEffect
max_fails1Failures within fail_timeout before the server is marked down
fail_timeout10sWindow for counting failures and how long the server stays marked down
max_fails=0n/aDisables failure tracking for that server
proxy_next_upstreamerror timeoutWhich failures trigger a retry on the next server
proxy_next_upstream_tries0 (unlimited)Maximum number of servers to try per request

Two details matter in production:

  1. POST requests aren't retried by default. Since Nginx 1.9.13, non-idempotent requests (POST, LOCK, PATCH) are only passed to the next server if you add non_idempotent to proxy_next_upstream. Leave it off unless the endpoints are safe to replay, or a payment could be charged twice.
  2. A "down" server comes back automatically. After fail_timeout, Nginx sends a real user request to the server to test it. If the server is still broken, that user gets the error or a retry.

Passive vs Active Health Checks

Passive (Nginx open source)Active (NGINX Plus health_check)
DetectionOnly when real traffic failsBackground probes on a schedule
Users affectedSome requests fail or retry before the server is marked downUsually none
Detects a broken idle backendNoYes
CostFreeCommercial license

Passive checks have a blind spot. A backend that's down but receives no traffic, such as a backup server, can sit broken for weeks without anyone noticing. Then the day you need failover, it fails too.

Step 4: Close the Gap with External Backend Monitoring

Fill the gap left by passive checks by giving every backend its own health URL and monitoring each one from outside. One option is a separate port per backend in Nginx:

# Health check passthrough per backend (restrict or protect as needed)
server {
    listen 8081;
    location = /healthz { proxy_pass http://10.0.0.11:3000/healthz; }
}
server {
    listen 8082;
    location = /healthz { proxy_pass http://10.0.0.12:3000/healthz; }
}
server {
    listen 8083;
    location = /healthz { proxy_pass http://10.0.0.13:3000/healthz; }
}

Then monitor the load-balanced site and each backend:

MonitorURLTells you
Public sitehttps://example.com/healthzWhat users actually experience
Backend 1http://lb.example.com:8081/healthzapp-1 is healthy
Backend 2http://lb.example.com:8082/healthzapp-2 is healthy
Backuphttp://lb.example.com:8083/healthzYour failover target will work when needed

GreenCrew runs these checks from outside your infrastructure. It alerts you on Discord, Slack, or Telegram when any backend fails, even while the public site still looks fine because Nginx is routing around the problem. That's the alert you want: fix app-2 while app-1 is still carrying the load, before you're down to zero healthy servers. Your public status page can show a "degraded performance" incident instead of a full outage. For the alert setup, see how to get website downtime alerts on Discord, Slack, and Telegram.

Step 5: See Which Backend Served Each Request

Add upstream details to your access log so you can debug uneven load or a failing node:

log_format upstream_log '$remote_addr [$time_local] "$request" $status '
                        'upstream=$upstream_addr ustatus=$upstream_status '
                        'rt=$request_time urt=$upstream_response_time';

access_log /var/log/nginx/access.log upstream_log;
# Requests per backend in the last 1,000 lines
tail -n 1000 /var/log/nginx/access.log | grep -o 'upstream=[^ ]*' | sort | uniq -c

When a retry happens, $upstream_addr lists both servers, for example 10.0.0.11:3000, 10.0.0.12:3000, which shows failover in action.

Zero-Downtime Deploys: Drain a Server

To take a backend out for maintenance, mark it down and reload:

server 10.0.0.12:3000 down;
sudo nginx -t && sudo systemctl reload nginx

Existing requests finish on the old workers, and new requests skip the server. Deploy, remove down, reload again, then repeat for the next server. This is the simplest form of a rolling deploy.

Troubleshooting Nginx Load Balancing

SymptomCauseFix
All traffic goes to one serverip_hash with most users behind one NAT or CDNUse least_conn, or hash on a cookie
Users randomly logged outSessions in local memory, no stickinessAdd hash $cookie_... or move sessions to Redis
no live upstreams while connecting to upstreamEvery server marked failedCheck backends; raise max_fails if they're flapping
Duplicate orders or paymentsnon_idempotent retries enabledRemove non_idempotent from proxy_next_upstream
invalid parameter "backup"backup used with hash, ip_hash, or randomUse round robin or least_conn with backup servers
Backup server broken during failoverPassive checks never tested itMonitor the backup's health URL externally

Frequently Asked Questions

Does Nginx open source support active health checks?

No. The health_check directive is only in the commercial NGINX Plus. Nginx open source uses passive checks through max_fails and fail_timeout. To catch failures before users do, monitor each backend's health endpoint with an external uptime monitor.

What is the default load balancing method in Nginx?

Weighted round robin. With no method directive, Nginx sends requests to each server in turn, adjusted by any weight values. For workloads where some requests take much longer than others, least_conn usually spreads the load better.

How many backend servers can Nginx load balance?

There's no practical hard limit. Nginx can balance across hundreds of upstream servers. Your limits are the backend capacity, network bandwidth, and the size of the shared zone, which you should increase for large upstream groups.

Is Nginx a good alternative to HAProxy or a cloud load balancer?

For HTTP workloads, yes. Nginx combines load balancing with TLS termination, caching, compression, and rate limiting in one process. HAProxy has active health checks in its free version and more detailed load balancing stats. Cloud load balancers remove server management entirely but cost more and offer less control.

Conclusion

Nginx load balancing needs an upstream block, a method such as least_conn, sensible max_fails and fail_timeout values, and careful proxy_next_upstream retries. Its one real gap in the open source version is that it can't see an idle or backup backend failing. Close that gap by monitoring every backend from outside with GreenCrew, so you get an alert and a status page update while you still have healthy servers left. To harden the same entry point, add rate limiting and Gzip and Brotli compression.