Symptoms
The pager goes off, or worse, a customer tells you before your monitoring does. The usual pattern looks like this: response times on sw-infrarunbook-01 spike from 80ms to several seconds, then the box stops responding to health checks entirely. SSH sessions hang after login, sometimes for thirty or forty seconds before you get a shell. Load average climbs but CPU usage looks oddly low, or conversely CPU is pegged at 100% on a single nginx worker. Outbound bandwidth graphs on your provider's panel show a wall of traffic that doesn't match your normal diurnal pattern. In my experience, the first tell is almost always that
ss -sshows an absurd number of connections in a state that's normally rare, like SYN-RECV or TIME-WAIT.
Here's what a healthy box looks like versus one under attack:
## Normal baseline
root@sw-infrarunbook-01:~# ss -s
Total: 187
TCP: 142 (estab 128, closed 6, orphaned 0, timewait 4)
## Under attack
root@sw-infrarunbook-01:~# ss -s
Total: 48213
TCP: 48190 (estab 12, closed 3, orphaned 1, timewait 88)
That gap between 142 total connections and 48,190 is the kind of number that tells you this isn't a slow API call or a bad deploy. It's an attack, and now the job is figuring out which kind, because the fix for a SYN flood is not the fix for an HTTP flood, and applying the wrong one wastes time you don't have.
Root Cause 1: TCP SYN Flood
A SYN flood works by sending a huge volume of TCP SYN packets, often with spoofed source IPs, and never completing the three-way handshake. Each half-open connection consumes a slot in the kernel's backlog queue until it times out. Once that queue fills, legitimate SYNs get dropped silently, and your server looks "down" even though the process behind the listening socket is perfectly healthy.
You identify it by looking at connection states directly:
root@sw-infrarunbook-01:~# netstat -ant | awk '{print $6}' | sort | uniq -c | sort -rn
47812 SYN_RECV
140 ESTABLISHED
88 TIME_WAIT
3 LISTEN
Forty-seven thousand connections stuck in SYN_RECV with almost nothing making it to ESTABLISHED is the signature. A tcpdump capture will usually show a flood of SYNs from a wide spread of source IPs, many of them clearly spoofed (private ranges appearing on a public interface, or sequential IPs that no real client population would produce).
root@sw-infrarunbook-01:~# tcpdump -nn -i eth0 'tcp[tcpflags] & tcp-syn != 0 and tcp[tcpflags] & tcp-ack == 0' -c 20
10:14:02.881211 IP 192.0.2.44.51201 > 203.0.113.10.443: Flags [S], seq 118823, win 65535
10:14:02.881233 IP 192.0.2.91.33221 > 203.0.113.10.443: Flags [S], seq 992104, win 65535
10:14:02.881250 IP 192.0.2.12.60112 > 203.0.113.10.443: Flags [S], seq 552013, win 65535To fix this on the box itself, enable SYN cookies immediately (many distros ship with this off or set conservatively) and tighten the backlog handling:
root@sw-infrarunbook-01:~# sysctl -w net.ipv4.tcp_syncookies=1
root@sw-infrarunbook-01:~# sysctl -w net.ipv4.tcp_max_syn_backlog=8192
root@sw-infrarunbook-01:~# sysctl -w net.ipv4.tcp_synack_retries=2SYN cookies let the kernel avoid allocating state for a connection until the handshake actually completes, which neutralizes most of the damage from a spoofed flood. This is a mitigation, not a cure — if the volume is large enough to saturate your NIC or upstream link, you need scrubbing at the network edge, which I'll get to in the prevention section.
Root Cause 2: UDP or ICMP Volumetric Flood
Volumetric floods aim to exhaust bandwidth rather than connection state. UDP floods are common because they're easy to spoof and easy to amplify (DNS, NTP, and memcached reflection attacks all fall in this bucket). The giveaway is bandwidth saturation with the CPU mostly idle — the box isn't struggling to process the packets, the pipe in front of it is just full.
root@sw-infrarunbook-01:~# vnstat -l
Rx: 940.12 Mbit/s 1,824,552 p/s
Tx: 12.44 Mbit/s 18,204 p/s1.8 million packets per second inbound on a server that normally sees a few thousand is not organic traffic. Confirm the protocol breakdown with tcpdump so you're not guessing:
root@sw-infrarunbook-01:~# tcpdump -nn -i eth0 udp -c 5000 2>/dev/null | awk '{print $NF}' | sort | uniq -c | sort -rn | head
4890 53
78 123
32 otherSource port 53 dominating means this is DNS reflection — attackers spoofed your IP as the source in queries sent to open resolvers, and the responses are now flooding you. There's no host-level fix that stops this cleanly; you cannot out-firewall bandwidth exhaustion on your own NIC. The only real mitigation is upstream — null-routing or scrubbing at your provider or a DDoS protection service before traffic reaches your uplink. What you can do locally is drop the traffic as early as possible to protect CPU for legitimate handling, and get on the phone with your hosting provider (in my experience, the sooner you open that ticket the faster they can null-route or blackhole at the edge):
root@sw-infrarunbook-01:~# iptables -A INPUT -p udp --sport 53 -j DROPRoot Cause 3: Application-Layer (HTTP) Flood
This is the one that looks the least like an "attack" at first glance, because the connections complete normally and nginx or Apache logs fill with what look like legitimate GET requests. The difference is volume and pattern — thousands of requests per second hitting the same few endpoints, often the login page or a search form, from a botnet with rotating user agents.
root@sw-infrarunbook-01:~# tail -5000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head
812 198.51.100.23
790 198.51.100.87
765 198.51.100.14
...If you see hundreds of requests per second from individual IPs, or a huge spread of IPs all hitting
/wp-login.phpor
/api/searchin the same second-by-second pattern, that's your L7 flood. Check nginx's own perspective on worker load too:
root@sw-infrarunbook-01:~# nginx -V 2>&1 | grep -o with-http_stub_status_module
root@sw-infrarunbook-01:~# curl -s http://127.0.0.1/nginx_status
Active connections: 8412
server accepts handled requests
902341 902341 4021884
Reading: 120 Writing: 6203 Waiting: 2089Writing at 6203 means thousands of workers are stuck sending responses, usually because they're serving the same expensive endpoint over and over. Mitigate with rate limiting at the nginx layer, which is fast to deploy and doesn't need a restart if you use a reload:
http {
limit_req_zone $binary_remote_addr zone=perip:10m rate=10r/s;
server {
location /api/search {
limit_req zone=perip burst=20 nodelay;
}
}
}root@sw-infrarunbook-01:~# nginx -t && nginx -s reloadRoot Cause 4: Slowloris-Style Connection Exhaustion
Slowloris and its variants don't send much traffic at all — they open connections and trickle partial HTTP headers just fast enough to avoid the server's timeout, tying up a worker thread indefinitely. This is nasty because bandwidth graphs look completely normal while the server is effectively unreachable, since every worker is pinned holding an incomplete request.
You catch this by looking at how long connections have been open relative to how little data they've sent:
root@sw-infrarunbook-01:~# ss -tnp state established '( dport = :80 or dport = :443 )' | wc -l
1024
root@sw-infrarunbook-01:~# ss -tni | grep -A1 ESTAB | grep -i 'rto\|rtt' | head -3
rto:204 rtt:1.2/0.4 ato:40If your worker count matches your max_clients setting exactly and has held steady for ten minutes with barely any bytes transferred, that's the pattern. The fix is aggressive timeout tuning:
root@sw-infrarunbook-01:~# grep -E 'client_header_timeout|client_body_timeout' /etc/nginx/nginx.conf
client_header_timeout 10s;
client_body_timeout 10s;Apache users should install and enable mod_reqtimeout or mod_qos. nginx is inherently more resistant to Slowloris because of how it handles connections asynchronously, and in my experience migrating a legacy Apache box under repeated Slowloris hits to nginx-as-reverse-proxy has ended the problem outright rather than just reducing it.
Root Cause 5: Botnet-Driven Distributed Sources (No Single IP to Block)
Sometimes the root cause isn't a specific protocol quirk, it's scale of distribution. You'll see a flood where no single source IP accounts for more than 0.1% of traffic, which means IP-based blocking is useless — you'd need to block thousands of addresses and new ones keep appearing.
root@sw-infrarunbook-01:~# tail -20000 /var/log/nginx/access.log | awk '{print $1}' | sort -u | wc -l
14822Fourteen thousand distinct IPs in twenty thousand requests tells you this is a real botnet, not a handful of misbehaving clients. At this point local mitigation buys time but doesn't solve the problem — you need a reverse proxy or CDN layer in front of the origin (a service like Cloudflare, or your provider's own scrubbing tier) that can absorb and filter traffic before it reaches sw-infrarunbook-01 at all. Locally, you can at least reduce load by serving cached responses instead of hitting the app server for every request:
location / {
proxy_cache mycache;
proxy_cache_valid 200 10s;
proxy_pass http://127.0.0.1:8080;
}Root Cause 6: Misconfigured Fail2Ban or Rate Limiting Making Things Worse
I've seen this one bite people during an actual attack: fail2ban or an overly aggressive iptables rule set starts consuming more CPU than the attack itself, because every packet triggers a chain traversal through hundreds of dynamically added rules. Check how large your chains have gotten:
root@sw-infrarunbook-01:~# iptables -L f2b-nginx-req -n | wc -l
8402Eight thousand rules in a single fail2ban chain will slow down every packet that hits the box, attack traffic or not. Use ipset instead of raw iptables rules for large ban lists — ipset does hash-based lookups instead of linear rule traversal, and the difference under load is dramatic:
root@sw-infrarunbook-01:~# ipset create banlist hash:ip timeout 3600
root@sw-infrarunbook-01:~# iptables -I INPUT -m set --match-set banlist src -j DROPRoot Cause 7: DNS Amplification Targeting Your Own Resolver
If your server also runs an open or semi-open DNS resolver, attackers can abuse it as part of a reflection attack against a third party, and you'll see your own outbound bandwidth spike even though you're the amplifier, not the ultimate victim. Check for this:
root@sw-infrarunbook-01:~# dig @203.0.113.10 ANY solvethenetwork.com +short
;; connection timed outTest whether your resolver answers queries from arbitrary source IPs it shouldn't. If it does, lock it down:
root@sw-infrarunbook-01:~# cat /etc/bind/named.conf.options | grep -A3 allow-query
allow-query { 10.0.0.0/8; 172.16.0.0/12; };Restricting allow-query to your internal RFC 1918 ranges only takes a resolver out of the amplification pool entirely, and it's worth checking even if you don't think of your box as a "DNS server" — plenty of default installs leave this wide open.
Prevention
None of the above steps make a determined, well-resourced DDoS go away permanently — they buy time and reduce collateral damage. The real prevention work happens before the attack: put a CDN or scrubbing provider in front of anything public-facing, so volumetric floods get absorbed upstream instead of hitting your uplink directly. Keep sysctl hardening (syncookies, backlog sizes, conntrack limits) as your default configuration rather than something you scramble to apply mid-incident. Baseline your normal traffic with something like vnstat or a Grafana dashboard fed from node_exporter, because you can't recognize "abnormal" if you don't know what normal looks like on sw-infrarunbook-01 at 3am on a Tuesday versus during a marketing push. Rate limit at every layer — nginx, application, and firewall — rather than relying on one choke point. And keep a written runbook with your provider's abuse/DDoS escalation contact and ticket process ready before you need it, because during an actual attack is the worst time to be hunting for the right phone number.
