I run a small fleet of servers: a few VPS hosting pet projects and a couple of small products. Open the access log on any of them and you see the same picture: day after day, someone is methodically working through the "juicy" paths — /.env, /.git/config, /.aws/credentials, /wp-login.php, /phpmyadmin.
This isn't a targeted attack. It's the background noise of the internet: botnets scan every IP in sequence, hoping to stumble on a forgotten .env with a database password or an exposed .git they can pull the source from. No single request is a problem; the volume is. One command shows how much of it you're getting:
# how many hits on the "juicy" paths have piled up in the access log
grep -cE '/\.(env|git|aws|ssh)|wp-login|phpmyadmin' /var/log/nginx/access.logThe load itself barely bothers me. nginx serves a regex 404 in microseconds, and even thousands of these requests a day don't register on the CPU. Two other things do hurt:
- Noise in monitoring. If you alert on 404s (and that's handy — it's how you catch broken links and botched deploys), the real 404s drown in the stream of
/.envand/wp-login.php. The useful signal is lost in the garbage. - Log bloat. Each such request is a line in the access log. Over a month that adds up to tens or hundreds of megabytes of junk you have to store, rotate and scroll past when investigating an incident.
I want a scanner like that to lose access to the host entirely after its first request to a known-bad path, instead of just collecting a 404. Here's how I did it with nginx + fail2ban + nftables.
Why "just rate-limit the 404s" doesn't work in nginx
The first idea is to throttle whoever generates a lot of 404s. The logic is sound: a normal user almost never hits a 404, a scanner hits nothing else. It seems like limit_req keyed on the 404 count would solve everything.
But nginx can't do that, and the reason is architectural. The ngx_http_limit_req_module and ngx_http_limit_conn_module modules run in the preaccess phase — that is, before the response is formed. At the moment the limiter makes its decision, the "404" status doesn't exist yet; it appears later, in the content phase. So there is no direct way to say "limit the requests that ended in 404": the limiter simply has nothing to check.
There are workarounds — count 404s from the log after the fact (with the same fail2ban setup), or build a map plus limit_req on specific URL patterns. But that is more complex and more finicky than it needs to be. I went another way, based on one simple observation:
No legitimate client ever requests/.envor/.git/config. So there is nothing to average and no threshold to tune. One hit on the trap is enough.
That turns the task from "a limiter keyed on response status" into "honeypot + fail2ban": nginx flags suspicious requests by writing the IP to a separate log, and fail2ban reads that log and bans at the firewall level.
The idea: a honeypot instead of a limiter
The whole scheme looks like this:
scanner
│ GET /.env · /.git/config · /.aws/credentials · /.ssh/id_rsa
▼
nginx · honeypot.conf — catches the "juicy" paths
│ └─ writes the hit (IP, time, path) to honeypot.log, drops the connection (444)
▼
nginx · deny-dotfiles.conf — any other /.<dotfile>
│ └─ plain 403, nothing written to honeypot.log
▼
fail2ban — watches honeypot.log, maxretry = 1
│ └─ one request to the trap = ban
▼
nftables · table inet f2b-table → addr-set-nginx-honeypot
│ └─ reject on all ports
▼
IP cut off entirely: an hour, doubling for repeat offenders — up to 128h
That comes to two nginx snippets and three fail2ban files. Step by step:
Step 1. The log: IP, time and request
For a ban, fail2ban only needs the IP. But it's handy to log the time and the request line as well — then you can see straight away which file they were after, and a real timestamp saves fail2ban from re-banning when it rescans the log (why that matters is covered in step 5).
The log format is declared with the log_format directive. One detail matters here: log_format is valid only in the http {} context — in nginx.conf or in a file it includes, such as /etc/nginx/conf.d/*.conf. You can't put it in a server snippet; nginx won't start. So the format is declared once, globally:
# IP, time and the request itself — shows which file they tried to reach
log_format honeypot '$remote_addr - [$time_local] "$request"';The resulting honeypot.log looks like this — one hit per line (the same IP repeats when a bot works through several paths):
93.123.109.178 - [27/Jun/2026:03:39:10 +0200] "GET /.env HTTP/1.1"
35.229.131.157 - [27/Jun/2026:03:40:02 +0200] "GET /.git/config HTTP/1.1"
107.170.47.137 - [27/Jun/2026:03:41:55 +0200] "GET /.aws/credentials HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:08 +0200] "GET /.env.local HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:09 +0200] "GET /.env.prod HTTP/1.1"
34.40.32.210 - [27/Jun/2026:03:42:11 +0200] "GET /backup.sql HTTP/1.1"Step 2. The trap — honeypot.conf
Now the honeypot itself. It's a location with a regex over known-bad paths. On a match, the hit goes to honeypot.log and the client gets nothing at all: nginx closes the connection with code 444:
# Honeypot: log scanners poking at sensitive paths and drop the connection (444).
# No $ anchor at the end — catches /.git/config, /.env.local and the like.
# Include BEFORE deny-dotfiles.conf so the hit makes it into honeypot.log
# (fail2ban tails that log and bans on the first hit). Ordinary missing pages
# get an honest 404 from the site's try_files — search engines must never see
# 403/444 on ordinary URLs.
location ~* ^/(\.env|\.git|\.aws|\.ssh|wp-login\.php|phpmyadmin|config\.php\.bak|backup\.sql) {
access_log /var/log/nginx/honeypot.log honeypot;
return 444;
}A few details that matter:
~*— a case-insensitive match, so/.ENVand/.Gitare caught too.- No
$anchor at the end —^/\.envcatches/.env,/.env.localand/.env.prodalike. - Besides dot-files, the list has a couple of "baits" no legitimate client requests (
config.php.bak,backup.sql), and in my snippetwp-login.phpandphpmyadminsit right in the main list too. That works only as long as the host runs neither WordPress nor phpMyAdmin — otherwise you'll ban your own admins; on such a server take those two paths out of the regex. return 444— a special nginx code: the connection is closed without a response, the scanner doesn't even get headers. The log line is still written.access_log … honeypotat thelocationlevel overrides the shared access log: these hits go only to honeypot.log and never reach the main log. Which means they disappear from the 404 alerts immediately.
Step 3. Silencing the other dot-files — deny-dotfiles.conf
The honeypot catches a specific list. But there are more dot-files: /.htaccess, /.htpasswd, /.svn, /.idea and so on. I don't ban for those (it could be someone's badly written client), but I do close access: a 403 for any path containing /.. The one exception is /.well-known/acme-challenge/, served from disk via try_files because that's where the Let's Encrypt HTTP-01 challenge lives. The rest of /.well-known/ (OAuth discovery, app challenges) the snippet leaves alone — those requests fall through to the site's own locations:
# Only the ACME webroot path is served from disk; the rest of /.well-known/
# (OAuth discovery, app challenges) falls through to the vhost's own locations — never intercept it.
location ^~ /.well-known/acme-challenge/ {
try_files $uri =404;
}
# Deliberate 403 for dotfiles (.git, .env, .ssh) — distinct from the honest
# 404 of missing pages. Include AFTER honeypot.conf: its patterns log first.
location ~ /\.(?!well-known/) {
return 403;
}Why 444 and 403 rather than 404. In the first version of this article both snippets returned 404, for uniformity: let what's blocked look like what doesn't exist. Now the codes are deliberately split into three classes. The trap returns 444: nginx silently closes the connection, there is no response at all. The remaining dot-files get an explicit 403. And the honest 404 is left to pages that are genuinely missing — the site's own try_files returns it. The rule is written right in the snippet: search engines must never see 403 or 444 on ordinary URLs, and the 404 must not be diluted by answers to junk paths. The old argument that "404 doesn't confirm the file exists" is gone from the snippet — a serious scanner needs a 200 with content anyway, not an error code.
About log noise. deny-dotfiles.conf has no access_log of its own, so its 403s land in the main log. They no longer reach the 404 alerts — that is exactly why the codes are split — but the lines stay in the log. If they bother you, add access_log off; to this location. My snippet doesn't have that line.
Step 4. Wiring the snippets into the sites
Both snippets are included in every server {}. The order is critical: honeypot first (it logs, so it has to fire earlier), deny-dotfiles after it:
# order matters: honeypot logs, deny-dotfiles silences the rest
include /etc/nginx/snippets/honeypot.conf;
include /etc/nginx/snippets/deny-dotfiles.conf;nginx picks the first matching regex location in declaration order. If deny-dotfiles is included first, its broad /\. intercepts the request for /.git/config and returns 403 before the honeypot gets to log the IP — and no ban happens. That's why the honeypot always goes above.
The same ordering logic applies to the other regex locations on the site. If you have, say, a location ~ \.php$ with fastcgi_pass declared above the snippets, the .php baits (in my list that's wp-login.php) go to the PHP handler and bypass the trap. Put the snippet includes at the top of server {}, before the application's locations; you can check the final order with nginx -T.
Check the config and reload nginx:
sudo nginx -t && sudo systemctl reload nginxStep 5. fail2ban: filter, jail and start
nginx now collects scanner IPs in honeypot.log. What's left is to teach fail2ban to read that log and ban.
The filter
Each line starts with the IP, then a space and a hyphen. fail2ban extracts <HOST> from that and recognises the [$time_local] date on its own:
[Definition]
failregex = ^<HOST> -^<HOST> -—<HOST>is fail2ban's macro for the IP, and^… -anchors it to the start of the line (the IP is followed by-). Every log line = one "failure", and withmaxretry = 1the first one is enough.- No
datepatternneeded:[$time_local]is the standard nginx log format, and fail2ban parses the date itself. If the log had no timestamp (datepattern = {NONE}), fail2ban would treat the moment it read a line as the time of the event: on a restart or a log rotation, old lines would look like fresh failures and long-gone scanners would get banned all over again. With a real timestamp that doesn't happen — lines older than thefindtimewindow are simply ignored.
You can test the filter against the live log before enabling the jail:
sudo fail2ban-regex /var/log/nginx/honeypot.log \
/etc/fail2ban/filter.d/nginx-honeypot.confThe output should show Matched for every line of the log.
The jail: one request and you're banned
The jail itself. This is where the whole ban policy lives:
[nginx-honeypot]
enabled = true
filter = nginx-honeypot
logpath = /var/log/nginx/honeypot.log
backend = auto
maxretry = 1
bantime = 3600
action = nftables-allports
bantime.increment = true
bantime.maxtime = 128hbackend = auto— read the file, specifically. If the[DEFAULT]section of yourjail.localsaysbackend = systemd(mine does), a jail without its own line reads the journal instead and never sees honeypot.log: zero hits, zero bans.autopicks pyinotify or polling on the file.maxretry = 1— one hit on the trap is enough. Nobody requests these paths by accident.bantime = 3600— the first ban is one hour. The first version used a day, but 24 hours mostly punished shared exit addresses (more on those below), and a persistent scanner climbs to the cap within a few days anyway.bantime.increment = true+bantime.maxtime = 128h— a repeat offender's ban doubles by fail2ban's default formula: 1h → 2h → 4h → … → 128h, a little over five days. Needs fail2ban ≥ 0.11; current Debian/Ubuntu have shipped newer for a long time.action = nftables-allports— ban via nftables, and on all ports at once rather than only 80/443. The scanner has already outed itself as hostile, so we cut off its access to the whole host.ignoreipisn't set in the jail — it is inherited from[DEFAULT]injail.local(next section).
Don't ban yourself: whitelist your own IP
This matters because nftables-allports cuts all ports, SSH included. If you go to test the trap by opening https://your-server/.env in a browser, fail2ban will dutifully ban your home IP — and you'll lose both the site and SSH access to the server.
So your permanent IP goes into ignoreip — preferably globally, for all jails at once, in jail.local:
[DEFAULT]
bantime = 1h
findtime = 10m
maxretry = 5
backend = systemd
# every jail inherits this — a sibling host's or the operator's auth flake must never ban.
# 203.0.113.10 is a placeholder for your IP; the real list (mine also has the Cloudflare ranges) lives in the inventory.
ignoreip = 127.0.0.1/8 ::1 203.0.113.10
# ban via nftables, to match the firewall the same role deploys (not iptables).
banaction = nftables-multiport
banaction_allports = nftables-allports
[sshd]
enabled = true
port = 22
[nginx-http-auth]
enabled = true
# nginx logs auth failures to error.log only (the stock filter has no journalmatch) —
# with the systemd backend from [DEFAULT] this jail would see 0 hits.
backend = auto
logpath = /var/log/nginx/error.logHere 203.0.113.10 is a placeholder (an address from the 203.0.113.0/24 documentation range); substitute your own static IP. If your address is dynamic, you can list your provider's subnet, or reach the server through a VPN with a fixed exit IP and whitelist that.
[DEFAULT]— shared values for every jail:bantime = 1h,findtime = 10m,maxretry = 5. The honeypot jail overridesmaxretry,bantimeandbackend(see above) and inheritsignoreipfrom here.backend = systemdin[DEFAULT]— which is why the jails that read files (the honeypot,nginx-http-auth) need their ownbackend = auto.banaction = nftables-multiport+banaction_allports = nftables-allports— both through nftables, to match the firewall on the same host; the honeypot jail picks allports explicitly viaaction.[sshd]and[nginx-http-auth]— two more jails on the same host: SSH brute force and basic-auth guessing in nginx. The emptyaddr-set-sshdin the dump below is the sshd jail's set.
When not to do this: false positives
The maxretry = 1 + nftables-allports combination is deliberately blunt and harsh: one hit, and the IP is cut off entirely. That's justified precisely because a legitimate client never requests /.env or /.git/config. But there is a flip side: shared addresses. Many real people can sit behind one IP — a mobile carrier's CGNAT, a corporate or university NAT. One infected client on such a network hits /.env, and the whole exit address lands in an all-ports ban, real users included.
On my pet projects the audience is narrow and technical, so the risk is acceptable: the odds that one of my real users sits behind the same mobile IP are vanishingly small. For a mass-market public service I wouldn't do this — I'd raise maxretry, shorten bantime, ban only 80/443 instead of allports, and keep only the completely unambiguous paths in the honeypot. Size up your audience before switching the trap on.
There is also the opposite case: addresses that shift under your feet. iCloud Private Relay and Cloudflare WARP send the client out through their own pool, and the exit address rotates. I checked this on my own phone: every reload of the trap arrived from a new relay address, that one got banned, and the next request already went through a third. For such clients an IP ban is leaky by nature — it catches only the current exit — while still hitting everyone who happens to sit behind that address at the moment. Those addresses are why the first ban now lasts an hour rather than a day.
Install and start
Install fail2ban (if it isn't there yet) and enable it at boot:
sudo apt update && sudo apt install fail2ban
sudo systemctl enable --now fail2banAfter editing the configs, reload fail2ban and check the status of our jail:
sudo systemctl reload fail2ban
sudo fail2ban-client status nginx-honeypotWhat ends up in nftables
The nftables-allports action creates a separate table, f2b-table, and keeps a set of banned addresses in it. The dump below is from the trap's first weeks, when the jail was still a hand-written file in jail.d and the first ban lasted a day. Since July 2026 an Ansible role deploys the same files; the table looks the same, only the list of addresses changes:
sudo nft list table inet f2b-table — show output
table inet f2b-table {
set addr-set-nginx-honeypot {
type ipv4_addr
elements = { 20.46.171.227, 34.40.32.210,
34.95.251.166, 35.229.131.157,
38.225.230.76, 45.148.10.62,
45.148.10.64, 45.148.10.120,
45.148.10.244, 45.198.224.25,
46.159.161.221, 62.4.29.86,
62.238.17.239, 65.49.1.66,
77.83.39.42, 77.83.39.94,
77.83.39.197, 85.11.167.118,
91.92.42.182, 91.92.241.196,
93.123.109.178, 107.170.47.137,
130.12.180.196, 135.235.138.254,
163.7.11.69, 176.65.139.231,
176.65.139.233, 176.65.139.235,
176.65.139.236, 176.65.139.238,
176.65.139.239, 195.178.110.199,
195.178.110.246, 213.142.151.136,
213.209.159.113, 216.73.160.2,
216.73.160.6 }
}
set addr-set-sshd {
type ipv4_addr
}
chain f2b-chain {
type filter hook input priority filter - 1; policy accept;
meta l4proto tcp ip saddr @addr-set-nginx-honeypot reject with icmp port-unreachable
meta l4proto tcp ip saddr @addr-set-sshd reject with icmp port-unreachable
}
}What's what here:
set addr-set-nginx-honeypot— the set of IPs banned by our jail specifically. fail2ban adds and removes addresses here as bans are issued and expire.set addr-set-sshd— the empty set of another jail, sshd (also on nftables). Different jails keep their own separate sets.chain f2b-chainhangs on theinputhook with priorityfilter - 1— slightly ahead of the regular filter. Any TCP packet from an address in the set is bounced withreject with icmp port-unreachable.
Useful commands: status and unban
To see who is banned right now and how many have been caught in total:
# stats and the list of IPs in our jail
sudo fail2ban-client status nginx-honeypot
# banned addresses at the firewall level
sudo nft list table inet f2b-tableIf you need to actually lift a ban (as opposed to clearing the log — that doesn't remove bans, fail2ban keeps its state in its own database):
# unban a specific IP
sudo fail2ban-client set nginx-honeypot unbanip 1.2.3.4
# lift all bans in all jails at once
sudo fail2ban-client unban --allPitfalls to avoid
- Whitelist first, enable second.
nftables-allportsbans every port, SSH included. Put your IP inignoreipbefore you go testing the trap, or you risk locking yourself out. - ACME and
/.well-known. The/\.regex also matches/.well-known/acme-challenge/…— the path certbot uses to prove the domain over HTTP-01. That's whydeny-dotfileshas(?!well-known/)and the challenge path gets its own^~location withtry_files: without that, Let's Encrypt renewals start getting 403s. DNS-01 is unaffected. log_formatbelongs only inhttp {}. You can't put it in a server snippet: nginx refuses to start with "log_format directive is not allowed here".- Include order matters. honeypot.conf strictly above deny-dotfiles.conf, otherwise the broad
/\.intercepts the hit and there is no ban. - Don't put paths people actually visit in the honeypot. With
maxretry = 1, any accidental request to a listed path = an instant ban. So the trap holds only what a legitimate client never requests at all. - Log rotation. honeypot.log lives in
/var/log/nginx/, so nginx's stock logrotate config (/var/log/nginx/*.log) picks it up — nothing to configure separately. fail2ban survives rotation on its own. - Behind a CDN or proxy,
real_ipalone is not enough. If nginx sits behind Cloudflare or a reverse proxy,$remote_addrholds the proxy's address, not the scanner's — and fail2ban bans your own proxy.set_real_ip_from+real_ip_headerfix who gets banned, not how: nftables on the origin can't cut traffic that keeps arriving from the proxy's edge addresses. The ban has to live on the proxy's side. How I stepped on this one is in the next section.
Behind a proxy: how I banned Cloudflare instead of the scanners
I wrote the last item in that pitfall list in advance — and stepped on it anyway. Some of my sites were behind the Cloudflare proxy at the time, and realip wasn't configured on the origin. nginx saw Cloudflare edge addresses in $remote_addr, dutifully wrote them to honeypot.log, and fail2ban just as dutifully banned them — on all ports, for up to a week. On July 19, 2026 I went through the jail statistics on one of the hosts: of 122 bans, 87 were Cloudflare addresses. That is self-DoS in its purest form: a banned edge can't reach the origin, so everyone Cloudflare routes through it gets an error instead of the site. The scanner itself, arriving via another edge, carried on as if nothing had happened.
The first half of the fix is obvious: realip. A snippet in http {} lists every Cloudflare range in set_real_ip_from and names the CF-Connecting-IP header:
# realip only fires when $remote_addr matches set_real_ip_from, so a direct
# connection that bypasses Cloudflare can't spoof CF-Connecting-IP.
set_real_ip_from 173.245.48.0/20;
set_real_ip_from 103.21.244.0/22;
set_real_ip_from 103.22.200.0/22;
set_real_ip_from 103.31.4.0/22;
set_real_ip_from 141.101.64.0/18;
set_real_ip_from 108.162.192.0/18;
set_real_ip_from 190.93.240.0/20;
set_real_ip_from 188.114.96.0/20;
set_real_ip_from 197.234.240.0/22;
set_real_ip_from 198.41.128.0/17;
set_real_ip_from 162.158.0.0/15;
set_real_ip_from 104.16.0.0/13;
set_real_ip_from 104.24.0.0/14;
set_real_ip_from 172.64.0.0/13;
set_real_ip_from 131.0.72.0/22;
set_real_ip_from 2400:cb00::/32;
set_real_ip_from 2606:4700::/32;
set_real_ip_from 2803:f800::/32;
set_real_ip_from 2405:b500::/32;
set_real_ip_from 2405:8100::/32;
set_real_ip_from 2a06:98c0::/29;
set_real_ip_from 2c0f:f248::/32;
real_ip_header CF-Connecting-IP;The ranges come from cloudflare.com/ips; in my Ansible they live in one list that feeds both this snippet and fail2ban's ignoreip, so the edge addresses can't end up banned even if realip fails to kick in somewhere.
The second half is less obvious. realip fixes who gets banned, not how. nftables on the origin rejects packets by source address, and behind a proxy the source is always a Cloudflare edge — including for a scanner that is already banned. The set gets a new entry; the traffic flows as before. For proxied sites the ban has to live on Cloudflare's side, so the jail grew a second arm: an action that creates an account-level IP Access Rule through the API. One POST blocks the address across every zone in the account, and when fail2ban lifts the ban, actionunban deletes the rule:
# second arm: the action itself lives in /etc/fail2ban/action.d/cloudflare-account.conf
action = nftables-allports
cloudflare-account[cftoken="…", cfaccount="…"]A ban on someone else's side has a loose end. The Cloudflare rule is deleted by the same fail2ban that created it, and the ban timer lives in its sqlite on the host. Wipe a host with active bans and its rules stay in Cloudflare forever. So every web host runs a sweeper once a day that deletes every rule tagged fail2ban older than 8 days. That is comfortably above the 128h cap, so no live ban gets cut short, and an orphaned rule outlives its host by about three days:
17 4 * * * root /usr/local/sbin/cf-ban-sweeperThis arm didn't live long. On July 20 I wrapped the API call in a case that checks the response, and fail2ban reads its configs with Python's configparser, which treats ; as an inline comment. The line was cut off at ;;, the shell died on the unclosed case, curl never ran — and the Cloudflare arm silently banned nobody. I only found that in the logs on September 18.
By then it no longer mattered. Cloudflare's edge is poorly reachable from Russia, and for Russian users the proxy was an extra foreign hop on top of an already foreign origin. On July 30 I un-proxied one host's records, on September 17 all the remaining ones. Not a single proxied record is left in the fleet, and Cloudflare's DDoS and WAF shield went with them — a deliberate trade. In return the origin sees real client addresses directly again, and a local nftables ban is enough: exactly the scheme described above. I left the Cloudflare action in the jail; it still creates a rule on every ban, but nobody comes through the proxy anymore, so the rules change nothing for the traffic.
Why not CrowdSec, a WAF, or bare nftables
A fair question: why build a trap by hand when ready-made tools exist? I'll go through the ones I considered myself and explain why I ended up staying with nginx + fail2ban + nftables.
CrowdSec. The spiritual successor to fail2ban: an agent parses logs against scenarios, bouncer components apply the bans, and the main selling point is collaborative blocklists. Addresses that have shown up at other participants' servers can be banned pre-emptively — before they ever knock on yours. But for my task that is extra moving parts: an agent, a local API, a bouncer and a scenario format of its own, versus three config files. Besides, fail2ban is already on the servers guarding sshd: adding one more jail is cheaper than bringing in a new stack. If you want reputation lists out of the box, though, CrowdSec is the one to look at.
A WAF or CDN in front of the server. Cloudflare with managed rules cuts scanners off before they reach the origin, and if all your traffic already goes through it, part of the problem is solved with a checkbox in the dashboard. What stopped me was something else: the ban exists on someone else's side, and the proxy only covers HTTP traffic. A scanner that has learned the server's direct IP keeps hammering past the WAF, whereas fail2ban with nftables-allports cuts it off at the host level, on all ports at once. Yes, you can also firewall the origin down to Cloudflare's published IP ranges — but that's one more piece of configuration to maintain. And since September 2026 the question has settled itself for me: none of my sites sit behind the proxy (the Cloudflare section above explains why), there is no WAF in front of the origin, and the ban on the host is the only one.
nftables without fail2ban. The firewall itself can do everything a ban needs: sets with a timeout, adding addresses on the fly. Only one thing is missing — a process that reads honeypot.log and puts the addresses into the set. Sit down to write that "little script" and it turns out it has to survive log rotation, remember bans across restarts, know how to unban, and escalate the duration for repeat offenders. Congratulations, you're writing your own fail2ban. I preferred the ready-made one, with a state database and twenty years of other people's mistakes already worked through.
Automation: one script for the whole setup
Everything above — two nginx snippets and three fail2ban files — I folded into one idempotent script: nginx-scanner-trap. The script detects your IP (so it won't ban you), asks which other addresses to whitelist, wires the snippets into every site, and installs and configures fail2ban. It backs files up before editing them, and if nginx -t fails, it rolls the changes back.
The safe route is download, read, run:
curl -fsSLO https://raw.githubusercontent.com/gistrec/nginx-scanner-trap/main/setup-honeypot.sh
less setup-honeypot.sh # read what it does
sudo bash setup-honeypot.sh # interactive: confirms your IP, asks about the whitelistYou can do a dry run first: --dry-run prints every action without changing anything. The script is the June shape of the scheme: both snippets return 404 rather than 444/403, the default ban is a day with a one-week cap (the --bantime and --maxtime flags change that), and wp-login.php, xmlrpc.php and phpmyadmin are behind the --aggressive flag — enable it only if the site has no WordPress and no phpMyAdmin. If you trust the code, there's a one-liner too:
curl -fsSL https://raw.githubusercontent.com/gistrec/nginx-scanner-trap/main/setup-honeypot.sh | sudo bashAll in all it's a couple dozen lines of config that noticeably clean up the logs and — what matters more to me — the 404 alerts. The scanners haven't gone anywhere, but now the first request to /.env sends the bot into a one-hour ban, and a repeat offender into one of up to 128 hours, instead of turning into a hundred log lines and an alert out of nowhere.