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 typeWhat it countsWhat it catches
conn_rate(<period>)TCP connection rate per key over the periodConnection floods, SYN-level abuse, rapid reconnect loops
http_req_rate(<period>)HTTP request rate per key over the periodScraping, API abuse, credential stuffing at the request layer
http_err_rate(<period>)HTTP error response rate per key over the periodDirectory 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 src and then read sc_conn_rate(0) in a tcp-request connection rule, 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.
  • expire must outlive the rate window. If expire is 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 sc0 but read with sc_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 reject acts before HTTP processing and increments dcon/dses; http-request deny acts per request and increments dreq. 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: size vs used. If used is at or near size, 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:

  • dreq rising while top table entries sit above your threshold: enforcement is working.
  • Table shows abusive entries but dreq/dcon are flat: the ACL is not matching. Check the sticky counter slot, the fetch name, the tracking directive’s evaluation point, and the threshold direction.
  • dreq spikes after a config change: your ACLs may now be denying legitimate traffic. Denied requests surface to clients as 403/429, so correlate with hrsp_4xx on 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

  1. Estimate peak unique keys. Take unique source IPs per minute at peak, multiply by (expire in 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.
  2. 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.
  3. Choose thresholds as ratios of legitimate behavior. Look at your real top talkers in show table before 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.
  4. Decide overflow policy explicitly. Default eviction keeps the table accepting new entries at the cost of tracking continuity. nopurge protects already-tracked clients at the cost of ignoring new ones. Neither is wrong; picking one deliberately beats discovering the default during an attack.
  5. 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

SignalWhy it mattersWarning sign
Stick table used vs size (show table)Overflow silently disables limiting for new sourcesused > 80% of size sustained
dreq / dcon / dses (frontend CSV)Proves the policy is denying; absence of denials during a known attack means broken enforcementFlat 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 underwayEntries far above baseline, or thousands of marginal entries
hrsp_4xx on the frontendDenied 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 instancesPeer disconnected or update counts frozen
New entry churn vs expiryEntry creation rate persistently exceeding expiry rate predicts overflow before it happensused 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, and dses, 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 dreq against hrsp_4xx separates 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.