InfraRunBook
    Back to articles

    HAProxy Rate Limiting and DDoS Protection Setup

    HAProxy
    Published: Apr 12, 2026
    Updated: Apr 13, 2026

    Learn how to configure HAProxy rate limiting and DDoS protection using stick tables, ACLs, and connection limits to defend against HTTP floods, scanner bots, and connection exhaustion attacks....

    HAProxy Rate Limiting and DDoS Protection Setup

    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

    src
    sample 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_syncookies
    setting 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 500000
    in 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

    frontend
    sections. 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 connection
    rules 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 reject
    sends 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-request
    is 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

    maxconn
    on 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-close
    in 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 leastconn
    on 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-Rate
    response 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

    429
    in 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 100
    to 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

    hping3
    from 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_rate
    counter 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/stats
    in 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 5s
    but 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 60s
    as 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.sock
    can 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:8404
    specifically — 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.


    Related Articles

    Frequently Asked Questions

    What is a stick table in HAProxy and why is it needed for rate limiting?

    A stick table is an in-memory key-value store built into HAProxy that tracks per-connection state, such as request rates, error rates, and concurrent connections keyed by source IP. Rate limiting in HAProxy is built on top of stick tables — without them, HAProxy has no memory of how many requests a given IP has made, so it cannot enforce per-client limits. You define a stick table in your frontend, populate it on each request with a track-sc directive, and then write ACL conditions that check the stored counters to decide whether to allow or deny the connection.

    How do I prevent HAProxy rate limiting from blocking legitimate users behind NAT?

    Users behind corporate NAT, university networks, or mobile carrier large-scale NAT all share a small number of source IPs, so aggressive per-IP rate limits can block many legitimate users at once. The safest approach is to set your IP-level thresholds conservatively high — high enough that a single user cannot hit them, but low enough that a real flood still triggers them. For tighter controls, track request rates on a combination of source IP and a session cookie or API key rather than IP alone, which gives you per-client precision without penalizing NATed users.

    What is the difference between http-request deny and http-request tarpit in HAProxy?

    http-request deny closes the connection immediately after sending the configured status code (such as 429), freeing the slot right away. http-request tarpit accepts the TCP connection and then deliberately never responds, holding the attacker's socket open until their client times out. Tarpit is effective against low-rate scanners because it ties up their resources without much cost to you. However, during a high-volume flood, tarpitting can backfire by exhausting your own connection table. Use deny for volume floods and tarpit selectively for low-rate probing behavior.

    How do I verify that HAProxy rate limiting is actually working after configuration?

    Use the HAProxy admin socket to inspect stick table contents in real time: run 'echo "show table fe_https" | socat stdio /var/run/haproxy/admin.sock' to see per-IP counters. Then use a tool like Apache Bench (ab) to send a burst of requests that exceeds your threshold and confirm you receive 429 responses. Check your HAProxy logs for denied requests, which will show the 429 status code in the log line. Finally, verify your whitelist works by confirming your monitoring IPs are not being denied during normal operations.

    Does HAProxy rate limiting protect against volumetric DDoS attacks?

    HAProxy rate limiting is very effective against application-layer attacks — HTTP floods, credential stuffing, scanner bots, and API abuse — because these attacks reach HAProxy as actual HTTP requests it can inspect and count. However, volumetric L3/L4 attacks that saturate your upstream network pipe never reach HAProxy at all; the packets are dropped at the ISP or transit router level. For volumetric protection you need upstream BGP blackholing or a DDoS scrubbing service. The correct architecture combines both: upstream mitigation for volume attacks, and HAProxy rate limiting for application-layer precision.

    Related Articles