DevOpsil

HAProxy Stick Tables For Session Persistence And DDoS Rate Limiting

Amara OkaforAmara Okafor8 min read

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 be ip, ipv6, integer, string, or binary
  • size 100k — maximum number of entries. 100k entries at default sizes uses roughly 50MB of RAM
  • expire 30m — entries older than 30 minutes get evicted
  • stick 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:

CounterWhat It Tracks
conn_curCurrent concurrent connections
conn_rateConnection rate over a time window
http_req_rateHTTP request rate
http_err_rateHTTP error response rate
bytes_in_rateInbound bytes per second
sess_cntTotal 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_rate etc.: ~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:

  1. Define a table with the key type and counters you need
  2. Track incoming connections/requests against that table
  3. 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.

Share:

Was this article helpful?

Amara Okafor
Amara Okafor

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

Discussion