It is 2 AM and your phone is buzzing. Monitoring says the box is unreachable. You SSH in — eventually, after the connection hangs for thirty seconds — and run uptime. Load average: 187. The server has four cores. You run top and the screen fills with php-fpm workers, every single one in a running state, each consuming a thin slice of CPU that adds up to total saturation. The OOM killer has not fired yet, but memory is climbing. You try to tail the access log and the terminal stutters. The box is drowning, and nobody is coming to help — there is no upstream scrubbing service, no network operations center watching a dashboard. It is just you, the server, and whatever is hitting it.
This is what a distributed Layer-7 HTTP flood looks like from the inside. Not a volumetric blast that saturates your uplink — your bandwidth graph might look almost normal. Instead, the attack sends a high rate of valid-looking HTTP requests to your most expensive endpoint, forcing your application backend to chew through each one. It is surgical, and it works precisely because every individual request looks legitimate.
Here is how you diagnose it, contain it, and kill it — all from the host itself, using nothing but nginx and standard command-line tools.
The obvious symptoms are predictable: load average through the roof, php-fpm workers exhausted, response times climbing from milliseconds to minutes, legitimate users getting 502s or timeouts. You might see nginx's error log filling with upstream timed out entries as the php-fpm socket backs up.
The thing that catches people off guard is that load average is a trailing metric. The 1-minute average you see right now includes data from up to 60 seconds ago. The 5-minute average is even more stale. This matters because when you start applying mitigations, the load will not drop immediately — it takes minutes for the averages to reflect reality. Do not assume your fix failed just because the number stays high for a while after you deploy it. Watch the trend, not the absolute value.
Before you block anything, you need to understand what you are looking at. Guessing gets you nowhere — or worse, it gets you blocking legitimate traffic while the attack continues. Start with the nginx access log.
Step 1: Top source IPs.
tail -n 20000 /var/log/nginx/access.log | awk '{print $1}' | sort | uniq -c | sort -rn | head -20
Read the output carefully. If one IP dominates — say, 8,000 out of 20,000 lines — you have a single-source flood and your life is simple: drop it in iptables and move on. But if the distribution is flat, with the top IP accounting for only a couple percent of total requests, that does not rule out an attack. It tells you the attack is distributed. Hundreds or thousands of sources, each contributing a small piece. This is the harder fight.
Step 2: Top requested endpoints.
tail -n 20000 /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -20
This is the tell. Legitimate traffic spreads across many URLs — the homepage, static assets (CSS, JS, images), various application routes. An L7 flood concentrates almost all requests on one dynamic path. If you see 85% of your recent requests hammering /portal/ or /app/ while everything else is in single digits, you are looking at a targeted flood against your most expensive endpoint.
Step 3: Count distinct IPs on that endpoint and inspect user agents.
# How many unique IPs are hitting the targeted path
tail -n 20000 /var/log/nginx/access.log | grep 'GET /portal/' | awk '{print $1}' | sort -u | wc -l
# What user-agent strings are they sending
tail -n 20000 /var/log/nginx/access.log | grep 'GET /portal/' | awk -F'"' '{print $6}' | sort | uniq -c | sort -rn | head -20
This is where the pattern becomes unmistakable. A distributed botnet often shows a large pool of unique IPs — hundreds, sometimes thousands — combined with a tiny set of near-identical, suspiciously clean user-agent strings. You might see three or four UAs, all recent browser versions, spread across a couple of operating systems, in roughly equal proportions. Real traffic shows far more variety: old browser versions, mobile browsers, crawlers, API clients, curl, feed readers, the whole zoo. When the UA distribution is too uniform, too clean, too perfect — that is a bot pool.
The diagnosis: Many source IPs + one target endpoint + a small rotating UA pool = a distributed L7 flood engineered to slip under per-IP rate limits. Now you know what you are fighting.
IP and range blocking is the first instinct. It fails against a distributed flood because the sources are spread across hundreds or thousands of unrelated networks — residential ISPs, cloud instances, compromised IoT devices across dozens of countries. There is no clean CIDR range to drop. You could start adding individual IPs to a deny list, but with a thousand sources rotating, you are playing whack-a-mole while the server burns.
Per-IP rate limiting is the second instinct, and it is a better one — but it has a ceiling:
# /etc/nginx/nginx.conf — inside http block
limit_req_zone $binary_remote_addr zone=flood:20m rate=5r/s;
# In the targeted location block
location /portal/ {
limit_req zone=flood burst=10 nodelay;
limit_req_status 429;
# ... proxy_pass or fastcgi_pass ...
}
This helps. It caps each IP to 5 requests per second with a burst allowance of 10. But do the math: if 1,200 IPs each get 5 requests per second through, that is still 6,000 requests per second hitting your php-fpm backend. On a dynamic endpoint that takes even 50 milliseconds per request, that is 300 concurrent workers needed — more than most configurations allow. Per-IP rate limiting reduces the flood, but against a sufficiently dispersed botnet, the sum of the trickles can still bury you.
It is tempting. You found a suspiciously uniform set of user-agent strings in the diagnosis step — three Chrome UAs covering 95% of the attack traffic. The instinct is to build an nginx map and drop anything matching that string. Do not do this.
The reason is simple: the flood used a real, extremely common browser user-agent. A current desktop Chrome string. That is precisely what made it effective — the bots blend into legitimate traffic. A regex that blocks Chrome/120.0.0.0 Safari/537.36 would also block a massive share of your actual human visitors, because that is the same string their browsers send. The UA uniformity was a diagnostic signal that confirmed you were looking at a botnet, not a blocking key. Turning it into a blocking key causes false positives that amount to self-inflicted downtime for your real users.
You need to key on something the bots do that legitimate users do not — and the answer is already in your diagnosis: the request path.
This is the core lesson. The flood concentrates on one path — often bare /, sometimes a specific application route. Your endpoint breakdown from Step 2 proved it: 85% or more of the request volume is hitting a single dynamic URL. Because it is one path, you can neutralize the entire flood by making nginx answer that path itself, for near-zero cost, so php-fpm is never invoked. Not a single worker tied up. Not a single byte of PHP execution.
The tool is an exact-match location block. The = prefix in nginx means "this exact path, nothing else." It does not match subpaths, query strings do not change the match, and it takes priority over regex and prefix locations. This surgical scoping is the whole point — the rest of your application (login, dashboard, API, cron, client area) is completely untouched.
Option 1: Hard drop (emergency stopgap)
location = / {
return 444;
}
Status 444 is nginx-specific — it closes the TCP connection immediately without sending a response. No headers, no body, no bytes. It is the cheapest possible rejection: nginx handles it entirely in the event loop, never touches the upstream socket, and the cost per request is negligible even at tens of thousands per second. Your php-fpm workers go from pinned to idle in seconds.
The trade-off is blunt honesty: 444 also drops any legitimate visitor who hits that exact path. If / is a genuine homepage that real customers land on, you just took it offline. That is why this is a stopgap — it buys you breathing room while the server stabilizes, and you replace it with something smarter once you can think straight.
Option 2: Redirect (sustainable fix)
location = / {
return 301 "https://example.com/portal/";
}
This is the fix you leave in place. A real browser follows the 301 redirect and lands on the actual application entry point — the user experience is a brief, invisible hop. But the flood bots — dumb HTTP clients that do not follow redirects — simply bounce off a cheap 301 response and never reach PHP. The redirect itself costs nginx almost nothing: a small response with a Location header, handled entirely in the event loop, no upstream connection opened.
A practical gotcha: if your redirect target includes a query string, the URL must be quoted in the return directive. Unquoted URLs with ? characters will cause a config parse error. The example above uses quotes, which is good practice regardless.
Both options share the critical property: PHP is never touched. The flood can send a million requests to / and your php-fpm workers will sit idle, serving the legitimate traffic that arrives on other paths. You turned an application-layer attack into a networking-layer non-event.
This safety check must happen before you deploy either fix. Dropping or redirecting a path is only safe if that path carries no — or negligible — legitimate traffic. You already have the data from Step 2, but look at it again with this specific question: do real users depend on this exact path?
# Check what traffic hit this path BEFORE the flood started
# Look at logs from earlier today or yesterday
awk '$7 == "/" {print $1}' /var/log/nginx/access.log.1 | sort -u | wc -l
If the path is the root / of a web panel that users access via /portal/ or /dashboard/, and / was already just a redirect or a skeleton landing page, you are safe. If it is the actual homepage of a customer-facing website with real organic traffic, a 444 drop would be self-inflicted downtime — use the 301 redirect instead, and make sure the redirect target is where your users actually need to be.
The verification comes from the logs, not from assumptions. Read them before you act.
When the server is on fire and you are editing nginx configs at 2 AM, sloppy habits will turn a bad situation into a catastrophic one. These are non-negotiable:
Back up the config before you touch it.
cp /etc/nginx/sites-enabled/example.com.conf /etc/nginx/sites-enabled/example.com.conf.bak-$(date +%H%M)
If your fix makes things worse, you need a one-command rollback. Not "I think I remember what it looked like before."
Always nginx -t before reloading.
nginx -t && nginx -s reload
The && ensures the reload only happens if the syntax check passes. A config error during a live flood — where nginx refuses to reload and you are stuck with the old config and a broken new one — is how incidents escalate from bad to unrecoverable.
Use nginx -s reload, not a restart. A reload gracefully re-reads the configuration without dropping existing connections. A restart tears down all connections and rebuilds the worker processes — during a flood, that means a window where nginx is not listening at all, followed by a thundering herd of backed-up connections when it comes back. Reload. Always reload.
Expect load average to fall gradually, not instantly. Backed-up php-fpm workers must drain their queues. The load average metric itself is trailing — the 1-minute average includes data from 60 seconds ago. Do not assume your fix failed because the number stays high for two minutes after you deploy it. Watch the trend, and use real-time indicators instead:
# Real-time: active php-fpm connections (should crater immediately)
watch -n 1 'ss -x | grep -c php-fpm'
# Real-time: requests hitting the blocked path
tail -f /var/log/nginx/access.log | grep --line-buffered '" 444 \|" 301 '
When the php-fpm connection count drops to single digits and the access log shows a wall of 444 or 301 responses on the flooded path, your fix is working — even if uptime still shows a scary number.
Verify from the logs, not from hope. After the reload, confirm that the status codes on the target path have flipped from 200 (PHP was processing them) to 444 or 301 (nginx is handling them). Backend 502 and 504 errors in the error log should dry up within seconds. If they do not, your location block is not matching — check for a trailing slash mismatch or a regex location that takes priority over your exact match.
Here is what the full defense looks like — the exact-match intercept, per-IP rate limiting as a second layer, connection limits, and tightened fastcgi timeouts:
limit_req_zone $binary_remote_addr zone=flood:20m rate=5r/s;
limit_conn_zone $binary_remote_addr zone=connlimit:10m;
server {
listen 443 ssl http2;
server_name example.com;
# Global connection limit per IP
limit_conn connlimit 15;
# THE FIX: take the flooded path out of PHP entirely
# Use 301 if real users hit this path; use 444 as emergency stopgap
location = / {
return 301 "https://example.com/portal/";
}
# Application routes — rate-limited, with tight timeouts
location / {
limit_req zone=flood burst=10 nodelay;
limit_req_status 429;
fastcgi_read_timeout 10s;
fastcgi_send_timeout 10s;
fastcgi_pass unix:/run/php/php-fpm.sock;
include fastcgi_params;
}
# Static assets — no rate limiting, no PHP
location ~* \.(css|js|png|jpg|jpeg|gif|ico|svg|woff2?)$ {
expires 30d;
add_header Cache-Control "public, immutable";
}
}
Notice the ordering. location = / (exact match) takes priority over location / (prefix match), so requests to exactly / get the cheap redirect while everything else — /portal/, /api/, /dashboard/, /admin/ — falls through to the normal application handling with rate limiting applied.
The fastcgi_read_timeout 10s is an underappreciated defense. By default, nginx will wait 60 seconds for php-fpm to respond. During a flood, requests pile up in the php-fpm queue, each holding a worker hostage for the full timeout. Dropping this to 10 seconds means stale requests get evicted faster, freeing workers for legitimate traffic. You lose the occasional slow page load under normal conditions, but during a flood, it prevents the queue from backing up catastrophically.
The load average is back in single digits. php-fpm workers are idle. Real users are loading pages again. You are not done yet.
Watch for path rotation. A competent botnet operator will notice the flood is bouncing off / and retarget to a different dynamic path. Re-run your endpoint breakdown every 15 to 30 minutes for the next few hours:
tail -n 10000 /var/log/nginx/access.log | awk '{print $7}' | sort | uniq -c | sort -rn | head -10
If a new path spikes, apply the same exact-match treatment. Keep your limit_req zone declared and ready — you can point it at any new target location in seconds.
Keep the mitigations in place. L7 floods come in waves. Removing the exact-match block and rate limiting an hour after the attack stops is how you get hit again at 4 AM. Leave them for at least 24 to 48 hours, then remove them gradually — rate limiting first, then the exact-match block — while watching the logs for a resurgence.
Plan for next time. You survived this one with nginx and the command line. That works, but it required you to be awake, alert, and fast. For a production server handling real revenue, the longer-term answer is a filtering reverse proxy or CDN sitting in front of your origin, plus fail2ban-style automation that can detect anomalous request patterns and deploy blocks without human intervention. Those are projects for tomorrow. Tonight, the box is stable and you earned the rest.
Your checklist for the next time the load average explodes:
✅ Read the logs first. Top IPs, top endpoints, top user agents. Diagnose before you act.
✅ Flat IP distribution means distributed attack. Shift your focus from blocking IPs to neutralizing the target path.
✅ Identify the concentrated endpoint. An L7 flood almost always hammers one dynamic path — find it.
✅ Confirm the path carries no critical legitimate traffic. Check the logs from before the flood. Do not guess.
✅ Take the path out of PHP. location = /path { return 444; } for emergency; return 301 to a real destination for the sustainable fix. Exact-match means only that path is affected.
✅ Do not block on user-agent. If the botnet uses a common real browser string — and the good ones do — blocking that UA takes out your legitimate users too.
✅ Layer per-IP rate limiting underneath. It will not stop a distributed flood alone, but it limits the damage from any single source.
✅ Back up configs, nginx -t before reload, reload not restart. Operational discipline prevents self-inflicted wounds.
✅ Load average is trailing. Watch php-fpm connections and access log status codes for real-time confirmation.
✅ Watch for path rotation. Re-run your endpoint breakdown regularly. The botnet may retarget.
✅ Keep the rules in place. Remove mitigations gradually, days later, while watching the logs.
With nothing but nginx, an access log, and an exact-match location block, one operator can absorb a distributed L7 flood that has no business being survivable on a single box. The diagnosis takes five minutes. The fix takes two lines of config and a reload. The key is knowing where to look and what to change — and now you do.
Done fighting floods alone? SwissLayer's managed Swiss infrastructure includes DDoS mitigation, proactive monitoring, and engineers who have dealt with this exact attack pattern. We wrote this guide because we fought this one today.