Prerequisites
Before diving in, you'll need HAProxy 2.4 or newer — ideally 2.6+ if you want the full stick table feature set including
sc_http_req_rate,
sc_bytes_in_rate, and the newer sample fetch functions. On Ubuntu 22.04 or Debian 12 the default repository version is sufficient. On older distributions, pull from the official HAProxy PPA or compile from source.
You'll also need root or sudo access on sw-infrarunbook-01 (your HAProxy host), a working rsyslog or syslog-ng setup capturing from
local0, and — critically — a baseline understanding of your normal traffic patterns. In my experience, the single biggest mistake teams make with HAProxy rate limiting is setting thresholds before they know what "normal" looks like. You end up either blocking legitimate users or setting limits so high they're useless against an actual attack.
Also think hard about your network topology before writing a single ACL. If your HAProxy instance sits behind a CDN or another reverse proxy, the
srcsample fetch returns the edge node's IP, not the attacker's. In that case you need to extract the real client IP from a header like
X-Forwarded-For— but only from upstream sources you actually control. Trusting that header blindly is how attackers spoof their way past your rate limits.
Understanding HAProxy's Rate Limiting Primitives
HAProxy doesn't have a single "rate limiting" toggle. It's built from composable primitives you wire together: stick tables, sample fetches, ACLs, and actions. Stick tables are the core of it. They're in-memory key-value stores that track state per connection source. Each entry can hold counters like concurrent connection count, connection rate, HTTP request rate, HTTP error rate, and bytes transferred — all with configurable sliding time windows.
Think of a stick table entry like this: for source IP
10.0.5.42, we're tracking how many HTTP requests it has made in the last 10 seconds. When that counter crosses a threshold, an ACL condition evaluates to true, and you act — deny the connection, return a 429, tarpit the socket, redirect to a captcha, whatever fits your policy.
Stick tables have a finite size and an expiry window. Entries that haven't been updated within the expiry interval get evicted. This is intentional — you don't want to hold per-IP state indefinitely — but it means your expiry window must be at least as long as your longest rate window. If you track
http_req_rate(30s)but set
expire 10s, entries evict before the window closes and your counters never accumulate. That's a silent misconfiguration that's embarrassingly easy to ship.
Step 1: Kernel Tuning on sw-infrarunbook-01
Get your kernel ready before touching HAProxy config. Under a real SYN flood, the kernel's SYN backlog fills before HAProxy ever sees the connection. Add the following to
/etc/sysctl.d/99-haproxy.conf:
net.ipv4.ip_local_port_range = 1024 65535
net.ipv4.tcp_tw_reuse = 1
net.core.somaxconn = 65535
net.ipv4.tcp_max_syn_backlog = 65535
net.core.netdev_max_backlog = 65535
net.ipv4.tcp_fin_timeout = 15
net.ipv4.tcp_syncookies = 1
Apply with
sysctl --system. The
tcp_syncookiessetting is particularly important — it lets the kernel respond to SYN packets without allocating a backlog entry, which is your first line of defense against SYN floods at the kernel level before HAProxy is even involved. Also bump HAProxy's file descriptor limit. Set
maxconn 500000in your global section and make sure the systemd service override (or
/etc/security/limits.conf) allows at least that many open files for the haproxy user.
Step 2: Define Your Stick Tables
Stick tables are defined inside
frontendsections. Here's a table definition that tracks four counters we'll actually use:
frontend fe_https
bind 10.10.1.10:443 ssl crt /etc/haproxy/certs/solvethenetwork.com.pem alpn h2,http/1.1
# 200k entries, 60-second expiry
# Tracks: concurrent connections, connection rate (10s), HTTP request rate (10s),
# HTTP error rate (10s), inbound bytes rate (10s)
stick-table type ip size 200k expire 60s \
store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(10s),bytes_in_rate(10s)
For 200k entries tracking five counters, expect roughly 100-120 MB of memory. On a busy frontend during a real DDoS, you'll want a larger table — I've run 500k-entry tables on 8 GB HAProxy nodes without issue. Size to your threat model, not to what fits comfortably on a development VM.
The
bytes_in_rate(10s)counter is one I added after dealing with an HTTP POST flood where the attacker was sending multi-megabyte payloads to an upload endpoint. Standard request rate limiting didn't catch it — it was only a handful of requests per second. But the byte rate was enormous and immediately obvious once I was tracking it. Always think about the specific attack surface your application creates, not just generic request rates.
Step 3: Track and Deny at the HTTP Layer
Once the table exists, populate it on every request with
http-request track-sc0, then define ACL conditions against the stored counters:
# Populate stick table on every HTTP request
http-request track-sc0 src
# Whitelist: internal subnets never get rate limited
acl src_trusted src 192.168.100.0/24 10.10.1.0/24
# ACL conditions from stick table counters
acl abuse_conn_rate sc_conn_rate(0) gt 60
acl abuse_req_rate sc_http_req_rate(0) gt 300
acl abuse_err_rate sc_http_err_rate(0) gt 50
acl abuse_bytes_in sc_bytes_in_rate(0) gt 500000
# Block high-rate abusers with 429
http-request deny deny_status 429 if abuse_req_rate !src_trusted
http-request deny deny_status 429 if abuse_conn_rate !src_trusted
http-request deny deny_status 413 if abuse_bytes_in !src_trusted
# Tarpit error-heavy IPs (scanners probing endpoints)
http-request tarpit if abuse_err_rate !src_trusted
The
sc_http_req_rate(0)sample fetch reads from counter slot 0 — the one populated by
track-sc0. HAProxy supports three counter slots (
sc0,
sc1,
sc2), which lets you track different keys in the same frontend simultaneously. You could track per-IP in slot 0 and per-URL in slot 1, for example, and make ACL decisions based on either or both.
The error rate ACL deserves special attention. Scanners and credential-stuffing bots generate a disproportionate number of 404s and 401s compared to legitimate traffic. Catching them on error rate often fires before they cross the request rate threshold, because they're not actually hammering you with volume — they're probing methodically. I've caught entire botnet scans this way that would have completely slipped past a pure request-rate limit.
Step 4: Reject Floods at the TCP Layer
HTTP-level rate limiting is great, but it runs after the TLS handshake completes. For a connection flood, you want to reject abusive IPs before burning CPU on TLS negotiation. Use
tcp-request connectionrules for this:
# TCP-level tracking fires before TLS handshake
tcp-request connection track-sc0 src
tcp-request connection reject if { sc_conn_rate(0) gt 100 } !{ src 192.168.100.0/24 10.10.1.0/24 }
tcp-request connection reject if { sc_conn_cur(0) gt 200 } !{ src 192.168.100.0/24 10.10.1.0/24 }
Notice the inline ACL syntax here —
{ sc_conn_rate(0) gt 100 }— rather than named ACLs. Both work, but for TCP-level rules I find inline conditions cleaner since there are typically only one or two of them. The
tcp-request connection rejectsends a TCP RST immediately, before any application data is exchanged. Under a real connection flood, this makes a significant difference in CPU and memory pressure on the HAProxy host.
Step 5: Protect Against Slowloris
Slowloris-style attacks don't flood you with connections — they hold them open indefinitely by sending HTTP headers one byte at a time, preventing the server from ever completing request parsing. You don't need ACLs for this. HAProxy has a dedicated timeout:
defaults
timeout http-request 10s
timeout http-keep-alive 5s
timeout http-requestis the maximum time HAProxy will wait to receive a complete HTTP request after the connection is established. If a client hasn't sent a full request within 10 seconds, the connection is dropped. This kills Slowloris without any stick table involvement. I have seen production configs where this timeout was missing or set to 60s+, and a single low-bandwidth attacker was able to exhaust the frontend's connection slots in minutes.
Step 6: Protect Your Backends with Hard Limits
Even with frontend rate limiting in place, always define hard connection limits on your backend servers. This protects against misconfiguration, a new frontend that bypasses your ACLs, or a sudden traffic spike that your rate limits don't catch in time:
backend be_app
balance leastconn
option httpchk GET /health HTTP/1.1\r\nHost:\ solvethenetwork.com
http-check expect status 200
timeout queue 10s
server app01 10.10.1.20:8080 maxconn 400 check inter 5s fall 3 rise 2
server app02 10.10.1.21:8080 maxconn 400 check inter 5s fall 3 rise 2
server app03 10.10.1.22:8080 maxconn 400 check inter 5s fall 3 rise 2
The
maxconnon each server tells HAProxy to queue excess requests rather than dumping them all onto a backend that can't handle them. Combined with
timeout queue 10s, requests that wait longer than 10 seconds in the queue get a 503, which is far better than an overloaded backend timing out after 30 seconds.
Full Configuration Example
Here's a complete, production-ready HAProxy configuration for sw-infrarunbook-01 with all the rate limiting and DDoS protection layers assembled:
global
log 127.0.0.1 local0
log 127.0.0.1 local1 notice
maxconn 500000
user haproxy
group haproxy
daemon
stats socket /var/run/haproxy/admin.sock mode 660 level admin expose-fd listeners
stats timeout 30s
tune.ssl.default-dh-param 2048
defaults
log global
mode http
option httplog
option dontlognull
option forwardfor
option http-server-close
timeout connect 5s
timeout client 30s
timeout server 30s
timeout queue 10s
timeout http-request 10s
timeout http-keep-alive 5s
#---------------------------------------------------------------------
# HTTPS Frontend with rate limiting and DDoS protection
#---------------------------------------------------------------------
frontend fe_https
bind 10.10.1.10:443 ssl crt /etc/haproxy/certs/solvethenetwork.com.pem alpn h2,http/1.1
# Stick table: 200k entries, 60s expiry
# Counters: concurrent conns, conn rate (10s), HTTP req rate (10s),
# HTTP error rate (10s), inbound bytes rate (10s)
stick-table type ip size 200k expire 60s \
store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(10s),bytes_in_rate(10s)
# --- TCP layer: reject floods before TLS handshake ---
tcp-request connection track-sc0 src
tcp-request connection reject if { sc_conn_rate(0) gt 100 } \
!{ src 192.168.100.0/24 10.10.1.0/24 }
tcp-request connection reject if { sc_conn_cur(0) gt 200 } \
!{ src 192.168.100.0/24 10.10.1.0/24 }
# --- HTTP layer ---
http-request track-sc0 src
# Trusted: internal monitoring and management subnets
acl src_trusted src 192.168.100.0/24 10.10.1.0/24
# Rate limit ACLs
acl abuse_conn_rate sc_conn_rate(0) gt 60
acl abuse_req_rate sc_http_req_rate(0) gt 300
acl abuse_err_rate sc_http_err_rate(0) gt 50
acl abuse_bytes_in sc_bytes_in_rate(0) gt 500000
# Hard deny for volumetric abusers
http-request deny deny_status 429 if abuse_req_rate !src_trusted
http-request deny deny_status 429 if abuse_conn_rate !src_trusted
http-request deny deny_status 413 if abuse_bytes_in !src_trusted
# Tarpit scanners (error-heavy, low request rate)
http-request tarpit if abuse_err_rate !src_trusted
# Expose current request rate in response header (useful for debugging)
http-response set-header X-RateLimit-Req-Rate "%[sc_http_req_rate(0)]"
# Route API traffic to application backend
use_backend be_app if { path_beg /api/ }
default_backend be_static
#---------------------------------------------------------------------
# HTTP Frontend: redirect to HTTPS only
#---------------------------------------------------------------------
frontend fe_http
bind 10.10.1.10:80
redirect scheme https code 301
#---------------------------------------------------------------------
# Backend: Application servers
#---------------------------------------------------------------------
backend be_app
balance leastconn
option httpchk GET /health HTTP/1.1\r\nHost:\ solvethenetwork.com
http-check expect status 200
timeout queue 10s
server app01 10.10.1.20:8080 maxconn 400 check inter 5s fall 3 rise 2
server app02 10.10.1.21:8080 maxconn 400 check inter 5s fall 3 rise 2
server app03 10.10.1.22:8080 maxconn 400 check inter 5s fall 3 rise 2
#---------------------------------------------------------------------
# Backend: Static content
#---------------------------------------------------------------------
backend be_static
balance roundrobin
option httpchk GET /ping
http-check expect status 200
server static01 10.10.1.20:8081 maxconn 800 check inter 5s fall 3 rise 2
server static02 10.10.1.21:8081 maxconn 800 check inter 5s fall 3 rise 2
#---------------------------------------------------------------------
# Stats page: internal access only, never expose publicly
#---------------------------------------------------------------------
listen stats
bind 127.0.0.1:8404
stats enable
stats uri /stats
stats refresh 10s
stats auth infrarunbook-admin:StrongPassHere
stats show-legends
stats show-node
A few configuration decisions worth explaining. The
option http-server-closein defaults tells HAProxy to close the connection to the backend after each request even if the client wants keep-alive. This forces connection reuse through HAProxy itself rather than pinning backend connections open, which matters a lot under load. Combined with
balance leastconnon the app backend, requests get distributed based on actual active connections rather than simple round-robin, which handles workloads where some requests take much longer than others.
The
X-RateLimit-Req-Rateresponse header is something I add to every rate-limited frontend. It lets your application developers see how close they are to the limit in real time from their curl output or browser dev tools, which saves a lot of "why am I getting 429s" support tickets.
Verification Steps
After reloading with
systemctl reload haproxy, verify everything actually works. Don't skip this. A clean reload with no config errors does not mean your ACL logic is correct.
First, confirm your stick table exists and is tracking entries:
echo "show table fe_https" | socat stdio /var/run/haproxy/admin.sock
Initially this returns nothing — entries appear only after connections are tracked. Hit your frontend from a workstation, then run it again. You should see output like:
# table: fe_https, type: ip, size:204800, used:1
0x55a4b2c3d180: key=10.10.1.50 use=0 exp=59812 shard=0 conn_cur=0 \
conn_rate(10000)=2 http_req_rate(10000)=6 http_err_rate(10000)=0 \
bytes_in_rate(10000)=4096
To test that rate limiting actually fires, use
ab(Apache Bench) from a test machine on the same network. Send a burst of requests and confirm you start receiving 429 responses:
ab -n 600 -c 30 https://solvethenetwork.com/api/test
# Watch for non-2xx responses in the output — you should see 429s appearing
# after the first ~300 requests cross the threshold
Then check your HAProxy logs on sw-infrarunbook-01. Denied requests look like this:
Apr 12 14:32:01 sw-infrarunbook-01 haproxy[12483]: 10.10.1.50:54321 \
[12/Apr/2026:14:32:01.043] fe_https be_app/app01 \
0/0/0/1/1 429 212 - - ---- 241/241/0/0/0 0/0 "GET /api/test HTTP/1.1"
The
429in the status field confirms the deny rule is firing. If you want to confirm which specific ACL matched, temporarily add
http-request capture req.hdr(User-Agent) len 100to your frontend and review the capture output in logs — it won't tell you the ACL name directly, but combined with the status code and timing it's usually obvious.
To verify TCP-layer protection specifically, you can simulate a connection rate spike using
hping3from a test machine:
hping3 -S -p 443 --flood 10.10.1.10
# On the HAProxy host, watch:
watch -n1 "echo 'show table fe_https' | socat stdio /var/run/haproxy/admin.sock"
You should see the source IP's
conn_ratecounter climb rapidly and then new connections from that IP start getting rejected. The stick table output updates in real time, which makes it an excellent live debugging tool during an actual incident.
Finally, pull up the stats page by SSH-tunneling to sw-infrarunbook-01 and opening
http://127.0.0.1:8404/statsin your browser. The "Sessions" column showing queued requests is your early warning for backend saturation. The "Denied" counter on your frontend climbs when rate limiting is active — if it's climbing during normal business hours, your thresholds might be too aggressive.
Common Mistakes
The most common mistake I see is forgetting to whitelist monitoring and internal subnets before deploying. Your uptime monitoring service hits the frontend constantly. If its source IP crosses the request rate threshold, HAProxy starts returning 429s, your monitoring alerts fire, and you spend an hour debugging what looks like a service outage but is just your own prober getting rate limited. Add your monitoring subnets to the trusted ACL before you go live, and test the whitelist explicitly.
The second mistake is mismatching expiry and rate windows. If your stick table has
expire 5sbut you're tracking
http_req_rate(30s), entries expire before the rate window closes. The counter resets and the rate limit never fires. Make your expiry at least 2x your longest rate window. I use
expire 60sas a default for any table tracking 10s windows — it gives enough buffer without holding memory longer than needed.
Third: not accounting for NAT. If a significant portion of your users sit behind corporate NAT or a mobile carrier's large-scale NAT, all their traffic appears as one or a few source IPs. A strict per-IP request rate limit will block an entire company's worth of legitimate users. I usually set IP-level limits conservatively high for this reason and rely on application-layer controls for tighter enforcement where I have session context to work with.
Fourth: confusing tarpit and deny semantics. A tarpit accepts the TCP connection but never responds, holding the attacker's socket open until it times out. This ties up a connection slot on your side too. Against a high-volume flood, tarpitting can actually hurt you by exhausting your own connection table. Use tarpit for low-rate scanners where you want to waste their resources; use hard deny for high-rate floods where speed of rejection matters.
Fifth: leaving the admin socket over-permissioned or the stats page on a public interface. The admin socket at
/var/run/haproxy/admin.sockcan drain servers, change weights, and clear stick tables. Keep it mode 660, owned by the haproxy group, and never let untrusted processes near it. The stats page in this config binds to
127.0.0.1:8404specifically — never bind it to a public interface, regardless of password protection.
One final thing worth being direct about: HAProxy rate limiting is not a substitute for upstream DDoS mitigation when the attack is volumetric. If an attacker is sending 20 Gbps at your network pipe, HAProxy never sees the packets — your upstream provider's links fill up first. HAProxy's rate limiting is excellent for application-layer attacks: HTTP floods, credential stuffing, scanner bots, API key abuse, scraping. For volumetric L3/L4 floods, you need BGP blackholing or a scrubbing center upstream. The right architecture uses both layers together — upstream mitigation for volume, HAProxy for application-layer precision.
