IP Hash #

IP hash is a load balancing algorithm that ensures requests from the same IP address are always directed to the same server. This implements session persistence (or sticky sessions) — the ability for one user to keep connecting to the same server throughout their session, without relying on any special cookie mechanism.

Why Session Persistence Is Needed #

Not every application needs session persistence. In fact, the need for it is often a symptom of a deeper architectural problem. But there are cases where it’s genuinely necessary:

Applications with local in-memory sessions. Older frameworks (default PHP sessions, some Express.js implementations) store session data in the local server’s memory. If requests from the same user land on a different server, that server doesn’t have their session data.

Multi-part file uploads. If a user uploads a large file in several parts (multipart upload, chunked upload), all parts must go to the same server so they can be reassembled correctly. A different server doesn’t have the previous chunks.

Multi-step wizard processes. Checkout, onboarding wizards, or multi-step processes that store temporary state between requests without a database.

Personal in-memory caching. Per-user caches stored in server memory — not Redis — require the same user to hit the same server for cache hits.


ip_hash Configuration #

upstream app_servers {
    ip_hash;   # Enable IP-based session persistence

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;

    zone app_upstream 64k;
}

server {
    listen 443 ssl;
    server_name example.com;

    location / {
        proxy_pass         http://app_servers;
        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;
    }
}

Nginx computes the hash from the first 3 octets of the client’s IP address ($remote_addr), not the full IP. So 203.0.113.1 and 203.0.113.254 will be hashed to the same server. This gives a little tolerance for users whose IP changes within the same subnet.

How the Hash Is Computed #

Client IP: 203.0.113.42

Nginx takes the first 3 octets: 203.0.113

Hash("203.0.113") → numeric value

value % number_of_servers = server index

As long as the number of servers doesn't change, 203.0.113.x ALWAYS
→ the same server

Temporarily Disabling Servers with ip_hash #

When using ip_hash, the way to disable a server is very different from plain round robin. Don’t remove the server from the configuration — use the down parameter:

upstream app_servers {
    ip_hash;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000 down;   # ← Use this, not deleting the line
    server 10.0.0.3:3000;
}

Why does this matter? Because ip_hash determines the server by hash % number_of_servers. If a server is removed from the list, the number of servers decreases — the entire mapping changes. All users who previously went to Server A might now go to Server B or C.

With down, the server is still counted in the hash calculation (keeping the mapping consistent), but Nginx doesn’t send requests there. Users who should go to the down server are automatically redirected to another server.

flowchart LR
    subgraph BEFORE["Before: 3 active servers"]
        IP1["IP 203.0.113.x → Server A"]
        IP2["IP 198.51.100.x → Server B"]
        IP3["IP 192.0.2.x → Server C"]
    end

    subgraph WRONG["❌ Remove Server B from the configuration"]
        W1["IP 203.0.113.x → Server A (ok)"]
        W2["IP 198.51.100.x → Server A or C (CHANGED!)"]
        W3["IP 192.0.2.x → Server A or C (CHANGED!)"]
    end

    subgraph RIGHT["✓ Mark Server B as down"]
        R1["IP 203.0.113.x → Server A (stays the same)"]
        R2["IP 198.51.100.x → Server A or C (redirected because B is down)"]
        R3["IP 192.0.2.x → Server C (stays the same)"]
    end

IP Hash Limitations #

IP hash has several limitations you must understand before relying on it:

1. NAT and Shared IPs #

All users behind the same NAT share one public IP. This includes:

  • All employees in one office
  • Users behind an ISP router doing CGNAT
  • Users behind a CDN or proxy (the CDN’s IP, not the client’s real IP)
Office A (100 employees) → all share the public IP 203.0.113.1
→ All 100 employees always go to Server A
→ Server A is overloaded, B and C sit idle

2. Nginx Behind a CDN #

If Nginx sits behind Cloudflare or another CDN, $remote_addr is the CDN’s IP, not the client’s real IP. All requests from the CDN will hash to the same server because they share the same IP.

# Solution: use the client's real IP from the CDN header
# (requires set_real_ip_from configuration)
set_real_ip_from 103.21.244.0/22;  # Cloudflare IP range
real_ip_header CF-Connecting-IP;

# After this $remote_addr contains the client's real IP
# ip_hash will work with the correct IP

3. Mobile Users #

Mobile users often change IPs when switching networks (WiFi → 4G → WiFi). Every IP change breaks their session persistence — the next request could go to a different server.

4. Uneven Distribution #

There’s no guarantee of even distribution. Distribution depends on the variation of IPs accessing the application — and on the internet, IP distribution isn’t uniform.


A Better Alternative: The hash Directive #

The hash directive gives full flexibility in choosing the variable used as the basis for session persistence:

upstream app_servers {
    # More reliable than ip_hash because it doesn't depend on IP
    # A session cookie is unique per user, unaffected by NAT or mobile
    hash $cookie_session_id consistent;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}

Hashing Based on a Custom Header #

upstream app_servers {
    # If the application sends a User-ID in a special header
    hash $http_x_user_id consistent;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

Hashing Based on a Variable Combination #

upstream app_servers {
    # IP + User-Agent combination — better than IP alone for NAT
    # (different users on the same NAT usually have different User-Agents)
    hash "$remote_addr$http_user_agent" consistent;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

The consistent Parameter: Consistent Hashing #

The consistent parameter uses the consistent hashing algorithm (ring/ketama hash). Its difference from regular hashing:

Regular hash: server = hash(key) % number_of_servers
  → If the number of servers changes, ALL mappings change
  → All users switch servers

Consistent hashing: the server is determined by position on a ring
  → If 1 server is added/removed, only ~1/N users switch
  → The other N-1 users stay on the same server

This is very useful for:

  • Cache servers — minimizing cache misses when scaling
  • Situations where server migration must be avoided
  • Upstreams that change frequently (auto-scaling)

Architectures That Eliminate the Need for Session Persistence #

Dependence on session persistence is a sign of an architecture that could be improved. Long-term solutions:

Move Sessions to Redis #

# No more ip_hash needed after this
upstream app_servers {
    # Round robin or least_conn — any server can serve anyone
    least_conn;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;

    keepalive 32;
}
// Node.js: store sessions in Redis, not local memory
const session = require('express-session');
const RedisStore = require('connect-redis')(session);
const redis = require('redis').createClient({ url: 'redis://redis:6379' });

app.use(session({
    store: new RedisStore({ client: redis }),
    secret: 'my-secret',
    resave: false,
    saveUninitialized: false,
}));
// Now all servers share the same session via Redis
// ip_hash is no longer needed

JWT for Stateless Authentication #

# With JWT, nothing needs to be stored on the server
# Every request carries all the necessary information
upstream app_servers {
    round robin;  # or least_conn, either is fine

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}
// Backend: verify JWT without touching the database/session store
app.use((req, res, next) => {
    const token = req.headers.authorization?.split(' ')[1];
    try {
        req.user = jwt.verify(token, process.env.JWT_SECRET);
        next();
    } catch (e) {
        res.status(401).json({ error: 'Unauthorized' });
    }
});

When ip_hash Is Still Worth Using #

Despite its many limitations, there are situations where ip_hash is a reasonable choice:

✓ Legacy applications that can't be modified for Redis sessions
✓ All users have distinct public IPs (no shared NAT)
✓ Nginx isn't behind a CDN (IP reaches users directly)
✓ No frequent horizontal scaling needs
✓ Distribution consistency isn't critical (some servers getting more is fine)

Debugging Session Persistence #

When session persistence isn’t working as expected, use these steps to diagnose the problem:

# 1. Verify the client IP Nginx receives
# Add temporary debug headers
# add_header X-Real-IP $remote_addr always;
# add_header X-Forwarded-For $proxy_add_x_forwarded_for always;
curl -sI https://example.com/ | grep -i "x-real\|x-forwarded"

# 2. Check whether Nginx is behind a CDN — if so, $remote_addr is the CDN's IP
# The output should be the client IP, not a Cloudflare IP (103.x.x.x)

# 3. Verify which server the request is directed to
# add_header X-Upstream-Addr $upstream_addr always;
curl -sI https://example.com/ | grep -i "x-upstream"
# Run several times from the same IP — it should always be the same server

# 4. Check from a different IP — it should be a different server
curl -sI --interface eth1 https://example.com/ | grep -i "x-upstream"
# Temporary debug configuration (REMOVE after debugging)
server {
    location / {
        proxy_pass http://app_servers;

        # Debug headers — shown to the client
        add_header X-Real-Client-IP  $remote_addr      always;
        add_header X-Upstream-Server $upstream_addr    always;
        add_header X-Request-ID      $request_id       always;
    }
}

Nginx Plus: Sticky Cookies #

If using Nginx Plus (paid), there’s a more sophisticated session persistence method using a special cookie inserted by Nginx:

# Nginx Plus only
upstream app_servers {
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;

    # Nginx Plus inserts a "SERVERID" cookie into the response
    # The client sends this cookie back on subsequent requests
    # Nginx reads the cookie to determine the destination server
    sticky cookie SERVERID expires=1h domain=.example.com path=/;
}

Advantages over ip_hash:

  • Unaffected by NAT — every browser has a unique cookie
  • No problem when users change IPs
  • The cookie can be set with a proper expiry
  • More even distribution because one cookie = one binding

Open-source alternative: use hash $cookie_session_id consistent (discussed above) — not as sophisticated as the Nginx Plus sticky cookie, but doesn’t require a paid license.


Session Persistence Method Comparison Table #

MethodOpen-SourceReliable vs NATReliable vs MobileEven Distribution
ip_hashYesNoNoNot guaranteed
hash $cookie_session_idYesYesYes (as long as the cookie exists)Better
hash $http_x_user_idYesYesYesYes (if user IDs are unique)
Sticky Cookie (Nginx Plus)NoYesYesYes
Redis Session StoreYes (needs code)YesYesN/A (stateless)

If you’re currently using ip_hash and want to migrate to a more reliable cookie-based solution, do it gradually to avoid breaking active user sessions:

# Step 1: Deploy an application version that can set the session_id cookie
# The new version must set the cookie before Nginx reads that cookie

# Step 2: Update the Nginx configuration to read the cookie
# (ip_hash stays active as a fallback for users without cookies)
upstream app_servers {
    # Transition phase: use the cookie if present, fall back to IP if not
    # (this can't be done directly in Nginx open source,
    #  but it can be done with a gradual approach)

    # BEFORE: only ip_hash
    # ip_hash;

    # AFTER: cookie hash
    hash $cookie_session_id consistent;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
    server 10.0.0.3:3000;
}
# A safer transition strategy:
# Use two separate upstreams and split traffic based on cookie presence
map $cookie_session_id $upstream_pool {
    ""       ip_hash_backend;    # No cookie → use ip_hash
    default  cookie_backend;     # Has a cookie → use cookie hash
}

upstream ip_hash_backend {
    ip_hash;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

upstream cookie_backend {
    hash $cookie_session_id consistent;
    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

server {
    location / {
        # Route based on cookie presence
        proxy_pass http://$upstream_pool;
    }
}

Once all users have a session cookie (usually after a few days or after all old sessions expire), remove the ip_hash_backend upstream and route all traffic to cookie_backend.

Important Migration Note: When migrating from ip_hash to hash cookie, make sure the application sets the session cookie before changing the Nginx configuration. If Nginx starts reading $cookie_session_id before the cookie exists, all requests without cookies will be hashed with an empty value — resulting in all cookie-less users being directed to the same server.

ip_hash Behavior for IPv4 vs IPv6 #

You need to understand that ip_hash behaves differently for IPv4 and IPv6:

IPv4 (203.0.113.42):
  Nginx takes the first 3 octets: 203.0.113
  Hash(203.0.113) → server
  Meaning: all IPs in the same /24 → the same server

IPv6 (2001:db8::1):
  Nginx takes the entire IP address
  Hash(2001:db8::1) → server
  No per-subnet grouping

In the increasingly widespread IPv6 era, this behavior can lead to better distribution (because there’s no /24 grouping like IPv4). But it also means IPv6 users who change IPs (e.g., because their ISP uses changing prefix delegation) will lose session persistence.

# To handle mixed IPv4/IPv6 traffic with session persistence:
# Use cookie hashing that doesn't depend on IP at all
upstream app_servers {
    hash $cookie_session_id consistent;

    server 10.0.0.1:3000;
    server 10.0.0.2:3000;
}

# Make sure the application sets the cookie before the first request to the upstream:
# Set the cookie at Nginx if it doesn't exist yet (for users without a session)
map $cookie_session_id $new_session_id {
    ""      $request_id;  # Generate an ID from request_id if not present
    default $cookie_session_id;
}

Summary #

  • ip_hash ensures requests from the same IP always go to the same server — useful for applications with local in-memory sessions.
  • Use the down parameter (not removing the server) to temporarily disable a server without scrambling all the hash mappings.
  • Critical limitations: NAT (many users sharing an IP → overloading one server), CDN (the CDN’s IP isn’t the client’s IP), mobile users (IP changes → sessions break).
  • hash $cookie_session_id consistent is a far more reliable alternative — independent of IP, more stable when scaling.
  • The consistent parameter uses consistent hashing rings — only a small fraction of users switch servers when servers are added or removed.
  • The best long-term solution: move sessions to Redis and make the application stateless — session persistence is no longer needed at all.

← Previous: Least Connections   Next: Weighted Load Balancing →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact