Most teams handle DDoS the same way: they wait until they’re under attack, then panic-add Cloudflare, cross their fingers, and call it a day. That’s not a strategy — that’s hope. And hope has terrible uptime guarantees.
The real problem is that people treat DDoS as a single thing. It’s not. A 100 Gbps UDP flood and a slow HTTP credential-stuffing attack are both called "DDoS," but they require completely different defenses. Conflating them means your expensive WAF does nothing during a volumetric flood, and your scrubbing center does nothing when an attacker sends 10,000 perfectly valid-looking login requests per minute.
This article breaks down what actually happens at each layer, what tools defend each layer, and how to stack them so you’re covered from the BGP route all the way up to the application handler.
The Attack Surface You’re Actually Defending
Before picking tools, get the taxonomy right.
Layer 3 (Network) attacks are blunt instruments. Think UDP floods, ICMP floods, IP fragmentation attacks. The goal is pipe saturation — fill your uplink so no legitimate traffic gets through. Amplification attacks (DNS, NTP, SSDP, memcached) live here too; they’re particularly nasty because an attacker can send 1 Gbps of spoofed traffic and bounce back 50–100 Gbps at your IP.
Layer 4 (Transport) attacks abuse TCP state. The canonical example is a SYN flood: send millions of SYN packets, never complete the handshake, exhaust the connection table. Your server stops accepting new connections while legitimate users get timeout errors. TCP RST floods and ACK floods also belong here.
Layer 7 (Application) attacks are surgical. They complete the full TCP handshake. They speak valid HTTP. They might even send syntactically correct requests. Slowloris holds connections open by drip-feeding headers. HTTP floods hammer your most expensive endpoints — the search page, the login form, anything that hits the database. These attacks are often indistinguishable from real users by raw traffic metrics alone.
A sophisticated attacker uses all three simultaneously: saturate the pipe to distract, exhaust the connection table to limit your response surface, and slip application-layer floods through whatever you managed to keep alive.
Layer 3/4 Defense
Your Kernel Is Your First Line of Defense
Before any tool, tune the kernel. These sysctl settings should be in /etc/sysctl.d/99-ddos.conf on every exposed server:
# SYN flood protection via SYN cookies — no memory per half-open connection
net.ipv4.tcp_syncookies = 1
# Shrink the SYN backlog window; default 1 second is too generous
net.ipv4.tcp_syn_retries = 2
net.ipv4.tcp_synack_retries = 2
# Reject spoofed packets via reverse-path filtering
net.ipv4.conf.all.rp_filter = 1
net.ipv4.conf.default.rp_filter = 1
# Don't respond to ICMP broadcast — stops smurf amplification
net.ipv4.icmp_echo_ignore_broadcasts = 1
# Ignore bogus ICMP error responses
net.ipv4.icmp_ignore_bogus_error_responses = 1
# Increase connection tracking table size for high-traffic servers
net.netfilter.nf_conntrack_max = 2000000
net.netfilter.nf_conntrack_tcp_timeout_established = 300
net.netfilter.nf_conntrack_tcp_timeout_time_wait = 10
Apply with sysctl -p /etc/sysctl.d/99-ddos.conf. These cost nothing and stop a non-trivial percentage of automated attacks before a single packet hits your application.
nftables: The Right Tool for 2026
iptables is legacy. nftables is what you should be using — it’s faster, the syntax is coherent, and it handles rate limiting natively without the xt_hashlimit gymnastics.
Here’s a production-grade ruleset for a web server:
#!/usr/sbin/nft -f
flush ruleset
table inet filter {
# Track connection states per source IP
set tcp_flood {
type ipv4_addr
flags dynamic, timeout
timeout 60s
}
set udp_flood {
type ipv4_addr
flags dynamic, timeout
timeout 30s
}
chain input {
type filter hook input priority 0; policy drop;
# Accept loopback unconditionally
iif lo accept
# Allow established/related connections — state tracking is cheap
ct state established,related accept
# Drop invalid packets immediately
ct state invalid drop
# ICMP rate limiting — allow pings but not floods
ip protocol icmp limit rate 10/second burst 25 packets accept
ip protocol icmp drop
# SYN flood protection: limit new TCP connections per source
tcp flags syn ip saddr @tcp_flood drop
tcp flags syn limit rate over 30/second burst 50 packets \
add @tcp_flood { ip saddr } drop
# UDP flood protection: limit UDP per source
ip protocol udp ip saddr @udp_flood drop
ip protocol udp limit rate over 100/second burst 200 packets \
add @udp_flood { ip saddr } drop
# Allow SSH from specific management ranges only
ip saddr { 10.0.0.0/8, 192.168.0.0/16 } tcp dport 22 ct state new accept
# Allow HTTP/HTTPS
tcp dport { 80, 443 } ct state new accept
# Drop everything else
drop
}
chain forward {
type filter hook forward priority 0; policy drop;
}
chain output {
type filter hook output priority 0; policy accept;
}
}
Save to /etc/nftables.conf, enable with systemctl enable --now nftables.
Gotcha: The limit rate over syntax in nftables applies globally, not per-IP. The pattern above uses dynamic sets to track per-source state, which is what you actually want. Using just limit rate without the set tracking is a common mistake — it rate-limits all SYNs combined, not per attacker.
BGP Blackholing (RTBH) — When the Pipe Is Full
If the attack volume exceeds your upstream bandwidth, no amount of packet filtering helps. You’re getting what’s called a "null route" situation whether you want it or not. The professional move is to get ahead of it with Remotely Triggered Black Hole (RTBH) routing.
Most serious hosting providers and transit providers support BGP communities for blackholing. The idea: you advertise your own IP (or a /32) to your upstream with a specific BGP community tag, and they null-route traffic to that IP at their routers — before it ever enters your network.
Common community formats (check with your provider):
65535:666— IANA-reserved, widely used as a convention- Provider-specific, e.g.,
174:666(Cogent),3356:9999(Lumen)
If you control your own BGP (you’re announcing your own ASN):
# Using ExaBGP to announce a blackhole route to upstream
# /etc/exabgp/exabgp.conf
neighbor 198.51.100.1 {
router-id 203.0.113.1;
local-address 203.0.113.1;
local-as 65001;
peer-as 65000;
family {
ipv4 unicast;
}
static {
# Blackhole the attacked IP with community tag
route 203.0.113.50/32 next-hop 192.0.2.1 community [65000:666];
}
}
The tradeoff is real: you’re taking yourself offline for that IP. But you’re making a controlled decision to do so, rather than having the attack make that decision for you while also damaging surrounding infrastructure.
Gotcha: RTBH is a last resort, not a first response. If you have legitimate users on that IP (most web servers), blackholing takes you offline just as effectively as the attack. Use it when the alternative is the whole host going down.
Layer 7 Defense
Layer 7 is where most operators underinvest and where modern attacks focus. The attacker has completed the handshake — kernel filters won’t help you here.
nginx: Rate Limiting and Connection Controls
nginx’s ngx_http_limit_req_module is underrated. Configured properly, it handles a surprising volume of HTTP floods:
# /etc/nginx/nginx.conf — http block
# Rate limit zones: keyed by IP, stored in shared memory
# req_zone: 10 req/s per IP for general traffic
limit_req_zone $binary_remote_addr zone=general:20m rate=10r/s;
# Tighter zone for login and API endpoints
limit_req_zone $binary_remote_addr zone=auth:10m rate=2r/s;
# Per-IP connection limit
limit_conn_zone $binary_remote_addr zone=conn_per_ip:10m;
# /etc/nginx/sites-available/yourdomain.conf
server {
listen 443 ssl http2;
server_name example.com;
# Max 20 simultaneous connections per IP
limit_conn conn_per_ip 20;
# General rate limiting with burst tolerance
# nodelay: don't queue burst requests, reject them immediately
limit_req zone=general burst=20 nodelay;
limit_req_status 429;
# Slow client protection — Slowloris mitigation
client_body_timeout 10s;
client_header_timeout 10s;
keepalive_timeout 30s;
send_timeout 10s;
# Limit request body size — stops large POST floods
client_max_body_size 10m;
location /wp-login.php {
limit_req zone=auth burst=5 nodelay;
limit_req_status 429;
# ... proxy pass
}
location /api/v1/login {
limit_req zone=auth burst=3 nodelay;
limit_req_status 429;
# ... proxy pass
}
}
Gotcha: burst without nodelay queues requests up to the burst limit, then processes them at the rate limit speed. Under an HTTP flood this means your nginx worker processes fill up with queued requests and memory consumption spikes. Always use nodelay on endpoints that could be flooded.
HAProxy: Layer 7 Protection with Stick Tables
HAProxy’s stick tables are a proper stateful tracking mechanism. They let you implement sophisticated behavioral detection without an external state store:
# /etc/haproxy/haproxy.cfg
frontend web_front
bind :80
bind :443 ssl crt /etc/ssl/certs/fullchain.pem
# Stick table: track request rate and connection count per IP
# expire: entries purge after 30s of inactivity
stick-table type ip size 200k expire 30s store conn_cur,conn_rate(10s),http_req_rate(10s),http_err_rate(10s)
# Update the table on every request
http-request track-sc0 src
# Block if > 200 requests in 10 seconds — adjust to your traffic profile
http-request deny deny_status 429 if { sc_http_req_rate(0) gt 200 }
# Block if > 50 errors in 10 seconds — bot probing behavior
http-request deny deny_status 429 if { sc_http_err_rate(0) gt 50 }
# Block if > 50 simultaneous connections from one IP
http-request deny deny_status 429 if { sc_conn_cur(0) gt 50 }
# Tarpit known bad actors instead of hard-blocking (wastes attacker resources)
http-request tarpit if { src -f /etc/haproxy/blocked_ips.txt }
timeout tarpit 30s
default_backend web_back
backend web_back
balance roundrobin
server app1 127.0.0.1:8080 check
The tarpit directive is a personal favorite — instead of rejecting the connection (which frees the attacker’s socket immediately), HAProxy holds it open for timeout tarpit duration, consuming attacker resources without serving them anything.
Gotcha: Stick tables are per-process. If HAProxy runs with multiple processes (nbproc > 1), tables aren’t shared. In 2026 you should be using nbthread (multi-thread, single process) instead of nbproc — tables work correctly there.
fail2ban: Automated IP Banning Based on Logs
For application-layer patterns that show up in logs, fail2ban remains a solid automation layer:
# /etc/fail2ban/jail.d/nginx-ddos.conf
[nginx-req-limit]
enabled = true
port = http,https
filter = nginx-req-limit
logpath = /var/log/nginx/error.log
maxretry = 10
findtime = 60
bantime = 3600
action = nftables-multiport[name=ReqLimit, port="http,https", protocol=tcp]
[nginx-botsearch]
enabled = true
port = http,https
filter = nginx-botsearch
logpath = /var/log/nginx/access.log
maxretry = 20
findtime = 60
bantime = 86400
# /etc/fail2ban/filter.d/nginx-req-limit.conf
[Definition]
failregex = limiting requests, excess:.* by zone .*, client: <HOST>
ignoreregex =
Gotcha: fail2ban reads log files. Under a serious flood, log file writes become a bottleneck and fail2ban’s regex scanning adds latency. It’s a reactive tool — it bans IPs after they’ve already hit your rate limits. Don’t rely on it as a primary defense; treat it as automated cleanup for repeated offenders.
The Stack That Holds Up Under Real Attacks
Here’s the honest layered picture:
Tier 0 — Upstream / CDN: Cloudflare, AWS Shield, Akamai, or your provider’s scrubbing center. For volumetric attacks above ~10 Gbps, this is not optional. You physically cannot handle that traffic at your edge. If you’re self-hosting on a dedicated server with a 1 Gbps uplink, a 50 Gbps UDP flood is a physical problem, not a software one.
Tier 1 — Kernel / NIC: sysctl tuning, SYN cookies, rp_filter. Zero cost, should always be on.
Tier 2 — nftables: Drop clearly malicious packets before they touch a userspace process. Rate-limit per source IP. Maintain dynamic ban sets.
Tier 3 — Reverse proxy (nginx/HAProxy): Rate limit HTTP requests per IP and per endpoint. Implement connection limits. Apply behavioral heuristics via stick tables. Return 429 rather than passing floods to your application.
Tier 4 — Application: Validate, authenticate, and log at the application layer. Feed suspicious patterns back to fail2ban. Implement your own rate limiting in the application code as a final backstop — don’t trust that the proxy always catches everything.
Tier 5 — Incident response: Have RTBH pre-negotiated with your provider before you need it. Know which BGP community to announce. Have a runbook. Under active attack is the wrong time to read documentation.
Gotchas Worth Calling Out Separately
Shared IP consequences: If your server is on a shared IP (VPS behind a provider’s NAT), an upstream blackhole hits every tenant on that IP. Know your topology.
IPv6 blind spots: Most teams implement DDoS mitigations on IPv4 and forget IPv6 entirely. Run nft list ruleset and check whether your IPv6 chain is doing anything useful. Attackers know this.
Asymmetric protection: A WAF on your HTTP port does nothing if your DNS server, SMTP relay, or game server port is unprotected. Map every exposed port. Rate-limit everything.
TLS termination cost: SSL/TLS handshakes are CPU-expensive. An attacker sending thousands of TLS ClientHello packets per second (without completing the handshake) can peg your CPU. Hardware acceleration helps; so does rejecting connections at the TCP level before TLS if the source IP is already in your bad-actor set.
Log volume during floods: Access logs during an HTTP flood can fill a disk in minutes. Use access_log off for 429 responses, or pipe logs to a rate-limited sink. Running out of disk space during an attack is embarrassing and avoidable.
What to Reach for First
Under active attack, work from the bottom up:
- Check if your uplink is saturated —
vnstat -lor your provider’s dashboard. If yes, call your provider for scrubbing or RTBH first. - If the pipe is fine but connection count is maxed — check
ss -s, look at SYN states. Tune syncookies, tighten nftables SYN limits. - If TCP connections look normal but your app is dying — look at access logs. Find the URL pattern. Add a specific rate limit. Identify the User-Agent or request signature. Block it.
Layer 7 attacks are harder to detect from aggregate metrics alone — CPU and connection counts can look normal while one endpoint is being hammered. Get comfortable with tail -f /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20 as a quick triage command during incidents.
Defense in depth here isn’t a buzzword — it’s just accurate. Any single layer fails in isolation. Your kernel filter won’t stop a sophisticated HTTP flood. Your WAF won’t stop a 40 Gbps UDP flood. The stack only works when all layers are present and tested before the attack starts.