We all know that within seconds of opening up a new website, it comes under attack. I just wanted a simple out of the box solution that perhaps could be added to nixos OOTB. Simple like this:
The Plan: Webserver replies 444 to any paths being scanned that we know are not ours. fail2ban scans the logs for 444 and uses the firewall to ban the offender for a hour
444 defender — day one
Yesterday we deployed this defender on our edge proxy: nginx answers known-bad paths with 444 (close the connection, send nothing), and a fail2ban jail bans any address that gets one — on the first request, no counting, no threshold.
Here is what day one actually looked like, measured rather than estimated.
The headline
| Before | Day one | |
|---|---|---|
| Requests per hour | ~2,500 (peak 13,600) | ~38 |
| Requests per day | 60,000 – 326,000 | ~900 |
| 404s per day | 9,100 – 326,000 | 19 |
| Scanners banned per day | ~1 | 256 |
A 98.5% reduction in request volume against a typical pre-defender day, and 99.7% against the worst one. The reduction is entirely in the traffic we never wanted.
The day-one trace
The defender went live on day 0. The daily request counts, taken straight from the access log:
day -2 170,076 ← before
day -1 326,695 ← before (peak)
day 0 54,167 ← deployed
day +1 37,216
day +2 11,217
Then we truncated the log to get a clean baseline, and here is the first thirteen hours:
23:00 26
00:00 19
01:00 34
02:00 34
03:00 17
04:00 61
05:00 18
06:00 38
07:00 86
08:00 37
09:00 40
10:00 34
11:00 34
12:00 8
Flat. No burst. No peak. Just a low, constant trickle of scanners being turned away.
What the traffic is made of now
396 444 ← the defender firing
33 400
25 200
19 404
9 405
5 301
12 - (connection closed before a status)
81% of all traffic is now the defender answering a scanner. The 404s — the thing the whole design was built to replace — fell from 9,100 a day to 19.
And the 404 jail went quiet: it recorded 19 failures and 1 ban overnight, against 395 and 304 for the 444 jail. Scanners that needed ten misses to trip the old jail now trip the new one on their first request. The old jail became a backstop for anything the defender does not recognise.
The ban is a hard block, not a suggestion
This is the part worth checking rather than assuming. A banned address, connected to directly:
$ curl http://34.39.215.86/
curl: (28) Operation timed out
The connection times out. The packet is dropped by nftables before it reaches nginx. There is no 444, no access log entry, no handshake — the address simply does not exist any more.
And for the repeat offenders, the pattern is exactly what you would want:
05:49 request → banned
06:50 request → ban expired, re-banned within the second
07:50 unbanned
08:00 request → banned again
One request per ban-window. Not a flood — a single probe an hour, each one ending in an immediate re-ban. The scanner is not being slowed down or discouraged; it is being removed from the set of addresses that can reach us.
256 distinct addresses were banned in those thirteen hours, with 307 ban events. The extra 51 are the repeat offenders coming back.
The most instructive thing in the day-one data was a single IP.
207.175.220.59 sent 50 requests in one second — a comprehensive sweep of the credential-file family:
GET / → 200 (the landing page)
GET /.amplifyrc → 404 ← walked past
GET /.env.anthropic → 444
GET /.env-sample → 444
GET /.env-example → 444
GET /.aws/.env → 444
GET /.env → 444
GET /.docker/config.json → 444
GET /.docker/laravel/app/.env → 444
GET /.docker/.env → 444
GET /.cursor/mcp.json → 404 ← walked past
GET /.claude/settings.local.json → 444
GET /.boto → 404 ← walked past
GET /.claude/settings.json → 444
GET /.aws/credentials → 444
GET /.aws/credentials.bak → 444
GET /.aws/config → 444
GET /.env.aws → 444
GET /.env.backup → 444
GET /.env.bak → 444
GET /.env.dev → 444
45 of 50 caught. Four walked past. The four were all the same shape — a dotfile-prefixed configuration path — and all were the same family as paths we already listed. We had /.vscode, /.claude, /.config; we did not have /.cursor, /.boto, /.amplifyrc, or /.htaccess.
They are now in the list, and the count of return 444 directives went from 250 to 262.
The "successful" traffic is mostly not real visitors. The referrer headers on the 200 responses point at two unrelated-looking domains that resolve, via wildcard DNS, to this box's own public address. The scanner lands on /, gets a 200, and starts probing. A single request sequence from one address:
GET / → 200
GET /robots.txt → 404
GET /sitemap.xml → 404
GET /listings → 404
GET /security → 404
GET /logs → 404
...hundreds more
So splitting the log by status would not separate "visitors" from "scanners" — the scanners get 200s too.
The numbers, plainly
Snapshot taken mid-afternoon on the second day. The log is live, so these will drift slightly — the shape is the point, not the last digit.
| Requests, day one | 489 over 13 hours |
| Of which 444 | 396 (81%) |
| 404s | 19 |
| Distinct scanners banned | 256 |
| Ban events | 307 |
| Active bans at any moment | ~30 |
| Request volume vs before | −98.5% (99.7% vs the worst day) |
| False positives | 0 |
The last row is the one we will watch most carefully. A false positive is a real person who mistyped a URL and lost an hour. So far, none.
What we are watching
- Whether the 404 rate creeps back as scanners adapt
- Repeat offenders, and whether longer bans are worth it
- Any false positive at all
- The path list, fed by the misses
And the next thing
The 404s are not gone — 19 a day is not zero. And they are the residue that the defender does not recognise: paths we have not thought of. Every one of them is a candidate for the list.
That is the loop: run the defender, read what got through, add it, repeat. It is not a finished system. It is a system that gets better every time a scanner teaches it something.
Measured on our edge proxy over a thirteen-hour window.

Top comments (0)