Sticky sessions (session persistence) make a load balancer send every request from the same client to the same backend server. They are needed when an application keeps session data in the memory of each server, such as logged-in users or shopping carts, instead of in a shared store. In this tutorial you will configure cookie-based persistence in HAProxy on Ubuntu 24.04, add an IP-based alternative for clients without cookies, see what Nginx open source offers, and verify the behavior with curl.

Prerequisites

To follow this guide you need:

  • One server running Ubuntu 24.04 LTS for the load balancer, for example a CubePath VPS, with a non-root user with sudo privileges.
  • Two backend servers running your web application, reachable from the load balancer on a private network. This guide uses 10.0.0.11 and 10.0.0.12 on port 80; replace them with your own addresses.
  • Port 80 allowed on the load balancer (sudo ufw allow 80/tcp).

When to use sticky sessions

Persistence solves a real problem but has costs. Before configuring it, consider the options:

ApproachHow it worksDrawback
Cookie persistenceThe load balancer sets a cookie naming the serverNeeds cookies; one server can end up hotter than others
Source IP persistenceThe client IP is hashed or stored in a tableMany users behind one NAT or proxy land on the same server
Shared session storeThe app stores sessions in Redis, Memcached or a databaseRequires application changes
Stateless tokensThe session lives in a signed token sent by the clientRequires application changes

When a sticky server fails, its users lose their in-memory sessions whatever method you use. A shared session store is the long-term fix; sticky sessions are the right tool when you cannot change the application yet, or when locality brings a real performance benefit (for example, local caches).

Step 1 - Preparing test backends

To see which server answers each request, each backend should return something that identifies it. If your application does not, you can use Nginx on each backend temporarily. On the first backend:

sudo apt update
sudo apt install -y nginx
echo "web1" | sudo tee /var/www/html/index.html

Repeat on the second backend, writing web2 instead. From the load balancer, check both:

curl -s http://10.0.0.11/
curl -s http://10.0.0.12/
web1
web2

Step 2 - Installing HAProxy

Ubuntu 24.04 ships HAProxy 2.8 LTS, which supports everything in this guide. On the load balancer, install it:

sudo apt update
sudo apt install -y haproxy
haproxy -v
HAProxy version 2.8.x-... 2024/.. - https://haproxy.org/

The default configuration in /etc/haproxy/haproxy.cfg contains global and defaults sections with sensible settings, including an admin socket at /run/haproxy/admin.sock that you will use later. You will append a frontend and a backend to it.

With cookie insertion, HAProxy picks a server for a client's first request using the normal balancing algorithm, then adds a Set-Cookie header with the server's identifier. On later requests the browser sends the cookie back and HAProxy routes to that server directly.

Open the configuration:

sudo nano /etc/haproxy/haproxy.cfg

Add the following at the end of the file:

frontend web_in
    bind *:80
    default_backend web_servers

backend web_servers
    balance roundrobin
    option redispatch
    cookie SERVERID insert indirect nocache httponly
    server web1 10.0.0.11:80 check cookie web1
    server web2 10.0.0.12:80 check cookie web2

What each part does:

  • cookie SERVERID insert: HAProxy creates a cookie named SERVERID when the client has none.
  • indirect: HAProxy removes its cookie from requests before forwarding them, and does not resend it if the client already has the right value, so the application never sees it.
  • nocache: adds Cache-Control: private to responses that set the cookie, so a shared cache does not hand one user's cookie to others.
  • httponly: hides the cookie from JavaScript. When you terminate TLS on this frontend, add secure as well.
  • cookie web1 on each server line: the value written in the cookie for that server. Use neutral names, since clients can see them.
  • option redispatch: if the server in the cookie is down, HAProxy sends the request to a healthy server instead of failing.
  • check: enables health checks, so HAProxy knows when a server is down.

Validate the file and reload HAProxy:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy
Configuration file is valid

Step 4 - Testing persistence

Without a cookie, round robin alternates between servers. From any machine that can reach the load balancer, send a few requests:

for i in 1 2 3 4; do curl -s http://your_server_ip/; done
web1
web2
web1
web2

Now store the cookie from the first response and send it on later requests, as a browser would:

curl -s -c cookies.txt http://your_server_ip/
for i in 1 2 3 4; do curl -s -b cookies.txt http://your_server_ip/; done
web1
web1
web1
web1
web1

Inspect the response headers to see the cookie HAProxy sets:

curl -sI http://your_server_ip/ | grep -iE "set-cookie|cache-control"
set-cookie: SERVERID=web2; path=/; HttpOnly
cache-control: private

Every request now carrying SERVERID=web2 goes to web2.

Step 5 - Draining a server for maintenance

Persistence makes maintenance harder, because you cannot just remove a server without breaking its users' sessions. HAProxy's drain state solves this: the server stops receiving new clients but keeps serving requests that carry its cookie, so sessions end naturally.

Put web1 in drain mode through the admin socket:

echo "set server web_servers/web1 state drain" | sudo socat stdio /run/haproxy/admin.sock

If socat is missing, install it with sudo apt install -y socat. Check the result: new clients without a cookie now always get web2, while your saved cookie for web1 (if you have one) still reaches web1:

for i in 1 2 3; do curl -s http://your_server_ip/; done
web2
web2
web2

Show the server states. The srv_admin_state column for web1 changes from 0 to a non-zero value while it is drained:

echo "show servers state web_servers" | sudo socat stdio /run/haproxy/admin.sock

When the server has no more active sessions, stop it, perform your maintenance, and return it to service:

echo "set server web_servers/web1 state ready" | sudo socat stdio /run/haproxy/admin.sock

Step 6 - Using source IP persistence

Some clients do not keep cookies: API clients, health checkers, or non-HTTP protocols in mode tcp. For those, HAProxy can remember which server each client IP used in a stick table. Unlike a pure hash, the table survives adding servers without remapping existing clients, and entries expire after inactivity.

Replace the backend web_servers block with this version:

backend web_servers
    balance roundrobin
    option redispatch
    stick-table type ip size 200k expire 30m
    stick on src
    server web1 10.0.0.11:80 check
    server web2 10.0.0.12:80 check
  • stick-table type ip size 200k expire 30m: stores up to 200,000 client IPs, each forgotten 30 minutes after its last request.
  • stick on src: looks up the client's source IP in the table and records the chosen server on the first request.

Validate and reload:

sudo haproxy -c -f /etc/haproxy/haproxy.cfg
sudo systemctl reload haproxy

Requests from the same machine now reach the same server without any cookie:

for i in 1 2 3 4; do curl -s http://your_server_ip/; done
web2
web2
web2
web2

View the table contents to see the stored IP and the server ID it maps to:

echo "show table web_servers" | sudo socat stdio /run/haproxy/admin.sock
# table: web_servers, type: ip, size:204800, used:1
0x...: key=203.0.113.25 use=0 exp=1795000 server_id=2 server_key=web2

Step 7 - Sticky sessions in Nginx

The nginx 1.24 package in Ubuntu 24.04 has no cookie insertion like HAProxy (the sticky directive comes from the commercial NGINX Plus). It offers hash-based persistence instead. On an Nginx load balancer (sudo apt install -y nginx), define the upstream group and the proxy in a file under /etc/nginx/conf.d/:

sudo nano /etc/nginx/conf.d/app.conf
upstream app_backend {
    ip_hash;
    server 10.0.0.11:80;
    server 10.0.0.12:80;
}

server {
    listen 80;
    server_name your_domain;

    location / {
        proxy_pass http://app_backend;
        proxy_set_header Host $host;
        proxy_set_header X-Real-IP $remote_addr;
        proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
    }
}

ip_hash hashes the first three octets of a client's IPv4 address (or the whole IPv6 address), so all clients in the same /24 go to the same server. If you need a key based on the full address or on an application cookie that the backend already sets, use the generic hash with consistent hashing, which remaps only a fraction of clients when a server is added or removed:

upstream app_backend {
    hash $remote_addr consistent;
    server 10.0.0.11:80;
    server 10.0.0.12:80;
}

To take a server out temporarily while preserving the hash distribution for other clients, mark it down instead of deleting the line. Test and reload:

sudo nginx -t
sudo systemctl reload nginx
nginx: the configuration file /etc/nginx/nginx.conf syntax is ok
nginx: configuration file /etc/nginx/nginx.conf test is successful

Repeated requests from one client now return the same backend, the same way as in Step 6.

Troubleshooting

Requests still alternate between servers with a cookie. Check that each server line has a cookie value and that the client really sends the cookie back (curl -b cookies.txt). Browsers also drop secure cookies on plain HTTP, so only add secure when the frontend uses TLS.

Users lose their session when a server goes down. This is expected with any sticky method: option redispatch keeps the site available, but the in-memory session lives on the dead server. Move sessions to Redis or a database if this is not acceptable.

One backend receives most of the traffic. With source IP persistence, a large office or mobile carrier behind one NAT maps to a single server. Cookie persistence spreads those users because each browser gets its own cookie.

haproxy -c reports an unknown keyword. Old tutorials use appsession, which was removed in HAProxy 1.6. Use cookie ... insert or a stick table instead.

Conclusion

You configured cookie-based sticky sessions in HAProxy, drained a server without breaking active sessions, added source IP persistence with a stick table, and saw the hash-based options available in Nginx. As next steps, terminate TLS on HAProxy and add secure to the persistence cookie, enable the HAProxy stats page to watch sessions per server, and plan a move to a shared session store so any server can handle any request.