WordPress runs in a Docker container on this host, published to the host’s ports, with Caddy in front. The wp-login.php brute force started the usual way, and then came the ?author=N requests — the enumeration probes that walk user IDs one at a time looking for a valid login name to aim at. fail2ban was already installed on the host, so this looked like a twenty-minute job. It ate most of an afternoon, and the reason is now the first thing I check on any Docker-published service.
The jail worked. The bans did nothing.
I wrote a jail, started it, and watched fail2ban’s status output count failures and then report a ban. Clean, no errors. Then I looked back at the access log: the same source address, still hitting wp-login.php, still walking author IDs. The ban existed inside fail2ban and did not exist anywhere the packets could see it.
The cause is boring once you know it. fail2ban’s default iptables action inserts a rule into the INPUT chain. On a bare-metal service that is correct — traffic arrives on an interface, hits INPUT, gets dropped. Docker breaks that assumption. A published port is handled by DNAT in the nat table’s PREROUTING chain, and after that translation the packet is routed forward rather than up into INPUT. It traverses FORWARD. So my drop rule sat in INPUT, politely, matching nothing, while the attacker’s packets took the FORWARD path straight into the container.
Docker’s own firewall handling makes the failure hard to see: Docker owns the FORWARD chain and rewrites it as services come and go, so rules hand-inserted there are not durable either. That is exactly what DOCKER-USER exists for — a chain Docker creates and deliberately leaves alone, evaluated before its own forwarding rules. Ban there and the packet dies before the port publish ever sees it.
ban placed in INPUT -> packet still arrives, container logs the attempt
ban placed in FORWARD -> works until Docker rewrites the chain
ban placed in DOCKER-USER -> works, survives container restarts
The second gotcha: the log file isn’t a file
My first filter pointed at the WordPress container’s Apache access log. That path is a symlink to stdout — inside a container the logs go to Docker’s log driver, so there is nothing behind that symlink for fail2ban to tail. fail2ban read zero lines, and it did not complain loudly enough for me to notice the first time.
What did have a real, growing file was Caddy’s JSON access log. I pointed the jail at that instead and wrote the filter against JSON rather than the combined log format, matching the login path and the author-enumeration query. The decision I made deliberately: keep the reverse proxy as the single observation point and leave the container’s logging alone. Every attempt to share a log file from a container out to the host adds a bind mount and a permission problem to the security path itself, and the proxy already sees every request that matters, with the real client address before any container networking rewrites it.
Proving it instead of trusting it
fail2ban saying “ban” is not evidence. The evidence is the traffic stopping. I waited for a source to cross the threshold, took the banned address out of the jail status, and watched it from the other side. 143.198.211.77 went from hammering wp-login.php to nothing — the requests stopped reaching the container entirely, which is the only confirmation that the rule is in a chain the packets actually traverse.
Two things I would tell anyone repeating this. First, if a jail on a Docker-published port appears to work but the attacker keeps coming, check which chain the action writes to before you touch the regex — I lost the most time re-reading filter patterns when the filter had been matching correctly all along. Second, verify with a real address and a real request, not with the jail’s own status output. The status page reports intent. The chain enforces it.
Guides & explainers
Filed under: Cybersecurity