Skip to content

HAProxy stick tables

How HAProxy stick tables actually work — types, data counters, retention, runtime inspection, and the production sizing math you need before you set size 100k.

A stick table is HAProxy’s in-process key-value store, indexed by an attribute of the connection (source IP, header value, cookie) and storing one or more counters per key. They are the engine behind sticky sessions, rate limiting, abuse detection, and stateful ACLs. This article covers the table model, the sizing math, the data types, and the runtime inspection patterns we use to debug them.

How to verify

For any backend or frontend that defines a stick table, the runtime API exposes its current contents:

echo "show table be_app" | sudo socat /run/haproxy/admin.sock -
echo "show table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock -
echo "show table be_app data.http_req_rate gt 10" | sudo socat /run/haproxy/admin.sock -
echo "show table" | sudo socat /run/haproxy/admin.sock -

show table (no args) lists every table in the running config with its size, used entries, and configured types. show table <name> dumps the entries. Filtering by data.X gt N returns only keys exceeding a threshold — useful for finding the heavy hitters.

What’s happening

A stick-table declaration looks like:

stick-table type <T> size <N> expire <D> [store <fields>] [peers <name>] [nopurge]
  • type — what the key is. ip (IPv4), ipv6, integer, string len <n> (variable-length string with max length), binary len <n>. Keys of different types cannot coexist in one table.
  • size — maximum number of entries. The table is preallocated; HAProxy reserves memory up front based on type + data fields + size.
  • expire — how long an entry survives without updates before eviction. After the last access + expire, the entry is dropped on the next sweep.
  • store — comma-separated data fields. Each adds memory per entry: gpc0 (general-purpose counter), gpc0_rate(window), conn_cnt, conn_cur, conn_rate(window), sess_cnt, sess_rate(window), http_req_cnt, http_req_rate(window), http_err_cnt, http_err_rate(window), bytes_in_cnt, bytes_out_cnt, bytes_in_rate(window), bytes_out_rate(window). Default if you store nothing is just server_id for sticky sessions.

Sizing math: a table with type ip size 100k and three rate counters consumes roughly: 100,000 entries × (key + per-counter overhead). For IPv4 keys with three rate counters, count on ~80-100 bytes per entry — ~10 MB for size 100k. For string keys, add the len value to that per-entry cost.

The peers clause is what makes tables survive reloads and replicate across instances — see HAProxy peer protocol. Without peers, the table is per-process and per-reload.

The procedure

  1. Define a table at the right scope. Tables live in a frontend, backend, or peers section. You can declare a table in a frontend and reference it from another frontend or backend by name:

    backend st_global
        stick-table type ip size 200k expire 1h store http_req_rate(10s),conn_rate(10s)
    
    frontend fe_http
        bind *:80
        http-request track-sc0 src table st_global
        default_backend be_app

    The track-sc0 ... table st_global is the cross-reference. This lets multiple frontends share one table.

  2. Increment counters. Counter types divide into two families:

    • Cumulative: _cnt (lifetime count since entry created). Reset only when the entry is evicted.
    • Rate: _rate(window) (sliding window count). The window argument is required; e.g., http_req_rate(10s) counts over a rolling 10-second window.

    The track-sc0 (and sc1, sc2) directive ties an incoming connection or request to a key in the table, auto-incrementing the rate counters as requests flow.

  3. Read counters in ACLs. The fetches sc_http_req_rate(N), sc_conn_cnt(N), etc., read the counter for tracking slot N (sc0, sc1, sc2):

    frontend fe_http
        bind *:80
        stick-table type ip size 100k expire 30m store http_req_rate(10s)
        http-request track-sc0 src
        acl high_rate sc_http_req_rate(0) gt 100
        http-request deny status 429 if high_rate
        default_backend be_app
  4. General-purpose counters. gpc0 and gpc1 are integer counters you increment from ACLs. Use them for arbitrary tagging:

    frontend fe_http
        stick-table type ip size 100k expire 1h store gpc0,gpc0_rate(1h)
        http-request track-sc0 src
        http-request sc-inc-gpc0(0) if { path /login } { method POST }
        acl too_many_logins sc_gpc0_rate(0) gt 20
        http-request deny status 429 if too_many_logins
  5. Inspect at runtime. The flat show table is your debugging tool:

    echo "show table be_app" | sudo socat /run/haproxy/admin.sock - | head -20
    echo "show table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock -
    echo "show table be_app data.gpc0 gt 5" | sudo socat /run/haproxy/admin.sock -
  6. Manipulate tables via runtime. Set a value, clear a key, clear the whole table:

    echo "set table be_app key 192.0.2.10 conn_cnt 0" | sudo socat /run/haproxy/admin.sock -
    echo "clear table be_app key 192.0.2.10" | sudo socat /run/haproxy/admin.sock -
    echo "clear table be_app" | sudo socat /run/haproxy/admin.sock -
  7. Persist via peers. Add a peers section and reference it. The table is pushed to peers continually, so on reload or instance loss the state is rebuilt:

    peers haproxy-peers
        peer hap1 10.0.10.5:1024
        peer hap2 10.0.10.6:1024
    
    backend st_shared
        stick-table type ip size 200k expire 1h store http_req_rate(10s) peers haproxy-peers

Common pitfalls

  • A table with size too small silently evicts entries even before expire. Under load, the oldest entries vanish to make room for new ones — your rate limit becomes effectively useless. Size for peak concurrent unique keys + headroom.
  • The string key type string len 64 reserves 64 bytes regardless of key length. Long keys are truncated. Choose len to fit your largest realistic key.
  • expire 30s on a rate counter resets the entry quickly. If your window is http_req_rate(10s), an idle 30 seconds wipes the history; the next request from that key starts at 0. Match expire to the longest rate window you care about.
  • Tracking with track-sc0 in two different frontends without table argument creates separate tables, one per section. Use table <name> to share.
  • set table key for a counter that does not exist in the store clause silently does nothing. Confirm the store clause includes the counter you are trying to set.

Stack Harbor uses stick tables for rate limits, abuse detection, and per-tenant quotas in HAProxy. The tables are sized from baseline traffic measurements, persisted via peers, and inspected during incident triage with a small wrapper around show table. Documentation of every table — what it stores, why, what reads it — lives in the operations runbook. This is part of how we run Managed Operations for clients with public APIs.