HAProxy Stick Tables For Session Persistence And DDoS Rate Limiting
HAProxy Stick Tables for Session Persistence and DDoS Rate Limiting
If you've been running HAProxy in production for any length of time, you've probably hit one of two problems: either your users keep losing their sessions because requests land on different backends, or you're trying to figure out how to rate-limit abusive clients without throwing expensive hardware at the problem. Stick tables solve both, and they're one of the most powerful — and underutilized — features in HAProxy's toolkit.
Let me show you how to use them properly.
What Are Stick Tables, Actually?
A stick table is an in-memory key-value store built into HAProxy. It tracks arbitrary data about clients — IP addresses, session cookies, SSL IDs — and you can use that data to make routing decisions or enforce limits.
The key insight is that stick tables are stateful. Most load balancer config is stateless — every request goes through the same matching logic in isolation. Stick tables let HAProxy remember things across requests, which opens up a whole class of problems you can actually solve.
Each table entry has a key (usually a client IP or session token) and one or more counters that HAProxy automatically tracks. Entries expire after a configurable timeout, so you don't need to worry about manual cleanup.
Basic Syntax and Table Types
Here's the fundamental structure:
backend my_backend
stick-table type ip size 100k expire 30m
stick on src
server app1 192.168.1.10:8080 check
server app2 192.168.1.11:8080 check
server app3 192.168.1.12:8080 check
Breaking this down:
type ip— the key type. Can beip,ipv6,integer,string, orbinarysize 100k— maximum number of entries. 100k entries at default sizes uses roughly 50MB of RAMexpire 30m— entries older than 30 minutes get evictedstick on src— "stick" requests from the same source IP to the same backend server
The stick on directive is where the magic happens. When a new request comes in, HAProxy looks up the source IP in the table. If there's an existing entry pointing to a specific server, that server gets the request. If not, HAProxy uses its normal load balancing algorithm and then records the assignment.
Session Persistence: Beyond Simple IP Stickiness
IP-based stickiness works but has real limitations. NAT, corporate proxies, and mobile networks mean multiple users often share an IP. Cookie-based stickiness is usually more accurate.
Here's a production-ready configuration for cookie-based session persistence:
frontend web_frontend
bind *:443 ssl crt /etc/ssl/certs/site.pem
default_backend web_backend
backend web_backend
balance roundrobin
# Create a cookie if none exists, track it in the stick table
cookie SERVERID insert indirect nocache httponly secure
stick-table type string len 32 size 50k expire 4h
stick on cookie(SERVERID)
stick store-response cookie(SERVERID)
server app1 192.168.1.10:8080 check cookie app1
server app2 192.168.1.11:8080 check cookie app2
server app3 192.168.1.12:8080 check cookie app3
The stick store-response line is critical — it tells HAProxy to record the cookie value from the response and associate it with the server that handled it. On subsequent requests, stick on cookie(SERVERID) looks up that value and routes accordingly.
One thing I always recommend: set your expire to match your application's session timeout. If your app sessions last 4 hours, your stick table entries should too. Mismatch here causes mysterious session drops that are annoying to debug.
DDoS Rate Limiting with Stick Tables
This is where things get genuinely interesting from a security perspective. Stick tables support several built-in counters you can track and act on:
| Counter | What It Tracks |
|---|---|
conn_cur | Current concurrent connections |
conn_rate | Connection rate over a time window |
http_req_rate | HTTP request rate |
http_err_rate | HTTP error response rate |
bytes_in_rate | Inbound bytes per second |
sess_cnt | Total session count |
Here's a practical rate-limiting configuration I use as a starting point for most projects:
frontend web_frontend
bind *:80
bind *:443 ssl crt /etc/ssl/certs/site.pem
# Stick table lives in the frontend for cross-backend sharing
stick-table type ip size 1m expire 10m store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(1m),bytes_in_rate(10s)
# Track all connections
tcp-request connection track-sc0 src
# Reject if more than 200 concurrent connections from one IP
tcp-request connection reject if { sc_conn_cur(0) gt 200 }
# Reject if connection rate exceeds 50 per 10 seconds
tcp-request connection reject if { sc_conn_rate(0) gt 50 }
# HTTP-layer rate limiting (after TCP connection established)
http-request track-sc1 src
# Reject if more than 200 HTTP requests per 10 seconds
http-request deny deny_status 429 if { sc_http_req_rate(1) gt 200 }
# Deny clients generating lots of errors (scanning/probing behavior)
http-request deny deny_status 429 if { sc_http_err_rate(1) gt 30 }
default_backend web_backend
Notice I'm using two separate sticky counters (sc0 and sc1). HAProxy supports up to three (sc0, sc1, sc2). I use sc0 at the TCP layer for connection-level tracking and sc1 at the HTTP layer for request-level tracking. This lets you apply different thresholds at different protocol layers.
The 429 Too Many Requests status code is the correct response for rate limiting — use it instead of 503 or 403 so clients know to back off.
Tarpit: A Better Response Than Rejection
For obvious abuse — scanners, credential stuffers, vulnerability probers — outright rejection is fine. But for borderline cases, consider tarpitting instead:
frontend web_frontend
bind *:80
stick-table type ip size 500k expire 5m store http_req_rate(10s),http_err_rate(1m)
http-request track-sc0 src
# Tarpit aggressive requesters — hold the connection open for 10 seconds
http-request tarpit if { sc_http_req_rate(0) gt 100 }
# Hard deny for the worst offenders
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 500 }
default_backend web_backend
Tarpitting holds the TCP connection open without sending a response. This is intentionally wasteful for the attacker — their threads/connections are tied up waiting, which reduces their effective throughput without you having to over-provision your own resources.
Sharing Tables Between Frontends
Here's something that trips people up: you can define a stick table in a backend and reference it from multiple frontends. This is useful when you want shared rate limits across multiple entry points:
backend rate_limit_table
stick-table type ip size 1m expire 5m store http_req_rate(10s),conn_cur
frontend api_frontend
bind *:8080
http-request track-sc0 src table rate_limit_table
http-request deny deny_status 429 if { sc_http_req_rate(0,rate_limit_table) gt 100 }
default_backend api_backend
frontend public_frontend
bind *:80
http-request track-sc0 src table rate_limit_table
http-request deny deny_status 429 if { sc_http_req_rate(0,rate_limit_table) gt 100 }
default_backend web_backend
Both frontends now share a single table. A client hammering both your API and your web frontend gets counted in the same bucket.
Peer Synchronization for HA Setups
In a high-availability HAProxy setup with multiple nodes, each node has its own stick table. A session pinned to server1 on haproxy-node1 won't be known to haproxy-node2 if failover occurs. The peers feature solves this:
peers haproxy_peers
peer haproxy1 192.168.0.10:1024
peer haproxy2 192.168.0.11:1024
backend web_backend
stick-table type ip size 100k expire 30m peers haproxy_peers
stick on src
server app1 192.168.1.10:8080 check
server app2 192.168.1.11:8080 check
HAProxy will replicate table updates between peers in near-real-time. Note that this adds network overhead, so be thoughtful about what you replicate — high-frequency tables used purely for rate limiting might not need peer sync if you can tolerate brief gaps during failover.
Monitoring Your Stick Tables
You can inspect stick tables via the HAProxy stats socket, which is invaluable for debugging:
# Show all stick tables and their stats
echo "show table" | socat stdio /var/run/haproxy/admin.sock
# Show entries in a specific table
echo "show table web_backend" | socat stdio /var/run/haproxy/admin.sock
# Show entries for a specific key
echo "show table web_backend key 203.0.113.45" | socat stdio /var/run/haproxy/admin.sock
# Manually remove an entry (useful for clearing false positives)
echo "clear table web_backend key 203.0.113.45" | socat stdio /var/run/haproxy/admin.sock
The clear table command is important for operations — you'll inevitably have a legitimate user or partner get rate-limited and need to clear their entry without waiting for expiry.
Sizing Your Tables Correctly
Getting table sizing wrong causes either OOM issues or entries getting evicted too aggressively. Here's a rough calculation:
Each entry's size depends on what you're storing:
- Base entry: ~50 bytes
conn_cur,conn_rate,http_req_rateetc.: ~8 bytes each- IP key: 4 bytes (IPv4), 16 bytes (IPv6)
A table of size 1m storing five counters for IPv4 addresses uses roughly:
1,000,000 × (50 + 4 + 5×8) = ~130MB
For most setups, a table of 500k-1m entries is plenty. Set your expire aggressively for rate-limiting tables (5-10 minutes) to keep memory usage bounded.
Putting It Together
Stick tables are one of those features that look intimidating in the docs but become intuitive once you use them. The pattern is always the same:
- Define a table with the key type and counters you need
- Track incoming connections/requests against that table
- Make decisions (stick to a server, deny, tarpit) based on the tracked values
Start with IP-based rate limiting on your public frontends using http_req_rate and conn_cur. Add session persistence with cookie tracking for your stateful applications. Layer in peer sync when you move to HA. Monitor via the stats socket.
The combination of L4 connection limiting and L7 request rate limiting in a single, lightweight component — with no external dependency on Redis or Memcached — is genuinely hard to beat for the operational simplicity it provides.
Was this article helpful?
DevSecOps Lead
Security-first mindset in everything I ship. From zero-trust architectures to supply chain security, I make sure your pipeline doesn't become your weakest link.
Related Articles
HAProxy ACL-Based Routing For Blue-Green Deployments Without Downtime
Blue-green deployments are one of the cleanest ways to ship without waking anyone up at 3am. HAProxy makes this surprisingly straightforward with ACL-based...
Fix HAProxy '503 Service Unavailable' Backend Down
Diagnose and fix HAProxy 503 Service Unavailable errors caused by backend servers failing health checks, connection limits, or misconfigured routing.
HAProxy Health Checks and Automatic Failover
Configure HAProxy health checks at TCP, HTTP, and application layers with automatic failover, backup servers, and alerting for production reliability.
HAProxy SSL Termination: Offload TLS Without the Headache
Configure HAProxy SSL termination to offload TLS from backend servers, including certificate management, hardening, and SNI-based routing.
HAProxy Load Balancing: From Installation to Production
Configure HAProxy for HTTP and TCP load balancing — installation, frontends, backends, health checks, ACLs, SSL termination, and the stats dashboard.
Git Worktrees For Managing Multiple Feature Branches Simultaneously
Stop stashing. Stop context-switching. Git worktrees let you check out multiple branches simultaneously in separate directories — each with its own working...