HAProxy per-source-IP rate limiting: stick tables, conn_rate, and http_req_rate
Per-source-IP rate limiting in HAProxy is built on stick tables: in-memory key-value stores that track counters per client key (usually the source IP) and expose those counters to ACLs. When it breaks, it breaks silently. A full table, a mismatched expire, or an IPv6 client population can disable your limiting without a single error in the logs, and most teams find out only after an abuse incident.
This is an operational reference: what to track, how to configure it, how to inspect the table at runtime, how to verify the policy is actually denying traffic, and the failure modes that make limiting stop working.
What a stick table gives you
A stick table is a fixed-size, in-memory store inside each HAProxy process. Each entry is keyed by something you choose (source IP, a header value, a cookie) and stores typed counters that HAProxy updates as traffic passes. For rate limiting, three data types matter:
| Data type | What it counts | What it catches |
|---|---|---|
conn_rate(<period>) | TCP connection rate per key over the period | Connection floods, SYN-level abuse, rapid reconnect loops |
http_req_rate(<period>) | HTTP request rate per key over the period | Scraping, API abuse, credential stuffing at the request layer |
http_err_rate(<period>) | HTTP error response rate per key over the period | Directory scanning (404s), brute force (401/403s), fuzzing (400s) |
All three are sliding-window averages, not instantaneous counters. A client that bursts to 200 req/s in one second and then stops will read as over-threshold for roughly the length of the window before the rate decays. Plan thresholds with that lag in mind.
The counters answer different questions. conn_rate sees connection-level abuse that never reaches HTTP. http_req_rate sees application-layer abuse riding on a few keep-alive connections, where conn_rate looks normal. http_err_rate separates chatty-but-legitimate clients from clients whose traffic is mostly failing, the classic scanner and credential-stuffing signature. Tracking only one leaves blind spots.
Reference configuration
An enforcement setup has three parts: the table definition, the tracking directive, and the ACLs that act on the counters.
frontend ft_http
bind :80
# Track per-source-IP connection, request, and error rates
stick-table type ip size 1m expire 60s store conn_rate(10s),http_req_rate(10s),http_err_rate(10s)
# Track every client from connection accept onward.
# Must happen here, not in http-request rules: tcp-request connection
# rules are evaluated before any http-request directive runs.
tcp-request connection track-sc0 src
# Enforce at the connection layer for connection floods
tcp-request connection reject if { sc_conn_rate(0) gt 50 }
# Enforce at the request layer
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 100 }
default_backend bk_app
Points that matter operationally:
- Evaluation order is the silent trap. If you track with
http-request track-sc0 srcand then readsc_conn_rate(0)in atcp-request connectionrule, the connection rule runs before tracking has happened, the fetch returns 0, and the reject never fires. No error, no log line. Track at connection level when connection rules depend on the counter. expiremust outlive the rate window. Ifexpireis shorter than the tracking window, entries vanish mid-window and counters reset, weakening enforcement and churning memory.- The sticky counter slot must match. If you track on
sc0but read withsc_http_req_rate(1), the fetch returns 0 and the rule never fires. One of the most common silent misconfigurations. - Connection-layer and request-layer rules are complementary.
tcp-request connection rejectacts before HTTP processing and incrementsdcon/dses;http-request denyacts per request and incrementsdreq. You will use these counters later to verify enforcement.
The enforcement flow, and where the observability hooks sit:
flowchart LR
C[Client source IP] --> F[Frontend]
F --> T["track-sc0 src
stick table lookup"]
T --> A{ACL rate check}
A -->|under threshold| B[Backend]
A -->|conn_rate over| R1["reject
dcon / dses"]
A -->|req_rate over| R2["deny 429
dreq"]
T -.->|table full| X["entry rejected or evicted
limiting silently off"]Inspecting the table at runtime
Stick tables are not in the CSV stats. You inspect them through the runtime API. This is the only way to see who is tracked and at what rate.
# List all tables with capacity and current entry count
echo "show table" | socat unix-connect:/var/run/haproxy.sock stdio
# Dump entries for a specific table (shows key, use, exp, and stored counters)
echo "show table ft_http" | socat unix-connect:/var/run/haproxy.sock stdio
# Top talkers: request rate above a threshold
echo "show table ft_http data.http_req_rate gt 50" | socat unix-connect:/var/run/haproxy.sock stdio
# Connection floods
echo "show table ft_http data.conn_rate gt 100" | socat unix-connect:/var/run/haproxy.sock stdio
# Likely scanners / brute force
echo "show table ft_http data.http_err_rate gt 10" | socat unix-connect:/var/run/haproxy.sock stdio
Entry lines show the key plus tracked values (for example http_req_rate(10000)=37), along with exp (time until expiry). Two things to check first during an incident:
- Table header:
sizevsused. Ifusedis at or nearsize, you are in overflow territory (see below) and limiting may already be degraded for new sources. - The shape of the top entries. A handful of IPs far above everyone else suggests targeted abuse. Thousands of IPs each slightly over threshold suggests either a distributed attack or a threshold set below your legitimate per-user rate. NAT and corporate proxies aggregate many users behind one IP, so their legitimate rate can be very high.
Verifying the policy is actually blocking
A rate limit that never fires is indistinguishable from a rate limit that is silently broken, unless you watch the denial counters. Pair table inspection with the frontend denial metrics:
# TCP-layer denials (connection rejects)
echo "show stat" | socat unix-connect:/var/run/haproxy.sock stdio | \
awk -F, '$2 == "FRONTEND" {print $1": dcon="$82" dses="$83}'
# Request-layer denials (ACL denies)
echo "show stat" | socat unix-connect:/var/run/haproxy.sock stdio | \
awk -F, '$2 == "FRONTEND" {print $1": dreq="$11}'
How to read these:
dreqrising while top table entries sit above your threshold: enforcement is working.- Table shows abusive entries but
dreq/dconare flat: the ACL is not matching. Check the sticky counter slot, the fetch name, the tracking directive’s evaluation point, and the threshold direction. dreqspikes after a config change: your ACLs may now be denying legitimate traffic. Denied requests surface to clients as 403/429, so correlate withhrsp_4xxon the frontend.- First enabling enforcement: expect a brief 4xx bump as previously-flowing abusive traffic starts getting denied. That is the policy working, not a regression.
These are cumulative counters that reset on every reload, so alert on delta rates, not absolute values.
Gotchas that silently disable limiting
None of these produce an error message.
Table overflow disables limiting for new IPs. The table is fixed size. When it fills, the default behavior is to evict expired (and then oldest) entries, silently, with no counter or log line. Under a flood of unique source IPs, entries are evicted before their natural expiry, their counters reset to zero, and new abusers cycle through the table faster than the window can convict them. With nopurge configured, eviction is disabled and new entries are rejected outright, so brand-new clients are never tracked or limited at all. Either way, limiting degrades exactly when you need it most. Size the table for peak concurrent unique keys plus headroom, and monitor utilization.
Expire/window mismatch resets counters. If expire is too short relative to the rate window, entries expire between requests, the counter restarts, and a steady abuser never accumulates a convicting rate. Keep expire comfortably longer than the tracking window.
Reloads wipe the table. Stick tables live in process memory. Every reload starts a new process with an empty table unless peers replicate state, and peer sync itself takes time. A reload during an active attack reopens the window. Frequent automated reloads in dynamic environments make this worse; it is the same counter-reset problem covered in the reload guide, with a security consequence.
IPv6 makes per-IP limiting much less effective. An attacker with a single /64 has effectively unlimited source addresses and can rotate through them, defeating per-IP tracking while flooding your table toward overflow. Two mitigations, used together: key on a prefix rather than a full address (aggregate by /64, or /48 where appropriate) instead of individual IPv6 addresses , and combine IP tracking with a second key such as an API token or cookie where your application has one. Also verify your table type actually stores IPv6 keys; IP key-type behavior has varied across versions.
Aggregated clients share one key. CDNs, NAT gateways, and corporate egress proxies concentrate many users behind one source IP. A per-IP threshold reasonable for direct clients will false-fire on these. Whitelist known aggregators, set a much higher threshold for them, or track a better key (verified X-Forwarded-For, an auth token) for that traffic class.
Sliding-window lag cuts both ways. After a burst ends, the reported rate takes roughly one window to decay. If automation bans clients on the current rate, a client that already stopped can still get banned seconds later; if you unban manually, the client stays blocked until the window clears.
Per-process tables diverge in multi-instance deployments. Each HAProxy instance has its own table. Without peers replication, an abuser is limited per instance, not globally, so the effective per-client budget is your threshold multiplied by instance count. With peers, broken replication (check show peers) silently produces the same divergence.
Sizing and tuning decisions
- Estimate peak unique keys. Take unique source IPs per minute at peak, multiply by (
expirein minutes), add headroom for bursts. A DDoS with rotating sources will try to exhaust whatever number you pick, which is why utilization monitoring matters more than the initial guess. - Pick the window to match the abuse pattern. Short windows (10s) catch sharp bursts but let slow-and-low scrapers through. Longer windows (60s+) catch slow abuse but react slowly. Many deployments run two tables or track two windows on one.
- Choose thresholds as ratios of legitimate behavior. Look at your real top talkers in
show tablebefore setting limits. A threshold below the 99th percentile of legitimate per-source rate will deny real users; one set at 10x the legitimate p99 catches only the egregious. - Decide overflow policy explicitly. Default eviction keeps the table accepting new entries at the cost of tracking continuity.
nopurgeprotects already-tracked clients at the cost of ignoring new ones. Neither is wrong; picking one deliberately beats discovering the default during an attack. - Memory is proportional to size times stored data types. Three rate counters per entry cost more than one. Stick-table memory is part of HAProxy’s pool usage, so account for it alongside connection buffers when capacity planning.
Signals to monitor
| Signal | Why it matters | Warning sign |
|---|---|---|
Stick table used vs size (show table) | Overflow silently disables limiting for new sources | used > 80% of size sustained |
dreq / dcon / dses (frontend CSV) | Proves the policy is denying; absence of denials during a known attack means broken enforcement | Flat during confirmed abuse, or sudden spike after config change |
Top-entry rates (show table <t> data.X gt N) | Identifies scrapers, bots, DDoS sources while the attack is underway | Entries far above baseline, or thousands of marginal entries |
hrsp_4xx on the frontend | Denied requests surface as 403/429; also catches scanner noise (400/404) and Slowloris (408) | Spike correlated with enforcement change, or uncorrelated spike meaning attack |
Peer sync state (show peers) | Diverged tables mean inconsistent limiting across instances | Peer disconnected or update counts frozen |
| New entry churn vs expiry | Entry creation rate persistently exceeding expiry rate predicts overflow before it happens | used climbing toward size over hours |
Severity guidance: per-source abuse signals are TICKET-level unless automated mitigation is wired in, at which point enforcement is self-acting and the alert becomes informational. The exception is table overflow on a security-critical table, which deserves urgent attention because your protection is down and you have no other way to know.
How Netdata helps
- Frontend denial counters over time. Netdata collects the HAProxy CSV stats, including
dreq,dcon, anddses, so you see enforcement activity as a time series instead of polling the socket by hand during an incident. - Denials correlated with 4xx responses. Overlaying
dreqagainsthrsp_4xxseparates policy denies (your rate limiter working) from client errors and scanner noise. - Saturation context during attacks. Session rate,
scur/slim, and request rate alongside denial counters tell you whether limiting is holding the line or connections are still piling up. - Reload detection. Counters reset on reload and stick tables empty with them; Netdata’s counter-reset handling and uptime tracking help you spot when a reload reopened the rate-limit window.
- Anomaly flagging on request and error rates. ML-based anomaly detection on frontend rates surfaces the early shape of a scrape or credential-stuffing run before it crosses your static threshold.
Related guides
- HAProxy 502 Bad Gateway: backend connection and response failures
- HAProxy 503 Service Unavailable: no server is available to handle this request
- HAProxy 504 Gateway Timeout: timeout server and the backend timeout cascade
- HAProxy backend losing servers: active server count and cascade risk
- HAProxy backend queue building (qcur): requests waiting for a free server slot
- HAProxy bandwidth (bin/bout): bytes in, bytes out, and capacity planning
- HAProxy compression overhead: comp_byp, CPU cost, and when compression backfires
- HAProxy connect time (ctime) high: network latency and backend accept-queue overflow
- HAProxy connection errors (econ): backend connections refused, timed out, or reset
- HAProxy connection reuse dropping: http-reuse, pooling, and handshake overhead
- HAProxy counter resets on reload: phantom drops and spikes in your metrics
- HAProxy scur approaching slim: the concurrent-session saturation signal






