You have already found the core issue: a ban on VM2 is too late.
VM2 does see the real client address in the HTTP logs if NPM passes X-Forwarded-For correctly, but at the network layer every connection to VM2 still originates from NPM on VM1. Therefore an nftables/UFW rule on VM2 can only block NPM — which is not exactly the intended security feature.
For your setup, I would put the actual enforcement at the ingress point:
- Run Fail2Ban on VM1.
- Parse the NPM access/error logs there.
- Let Fail2Ban add/remove bans locally on VM1.
- Ensure NPM logs the real client IP, and only trust forwarded-IP headers from proxies you actually control.
- Keep VM2 restricted so it accepts service traffic only from VM1/NPM where possible.
Having Fail2Ban on VM2 execute remote firewall actions on VM1 via SSH can work, but it is basically building a small, brittle distributed ban system yourself: SSH keys, narrowly scoped sudo rules, reliable unban actions, error handling, and so on.
Alternative:
If you want detection from several VMs/services but enforcement centrally at NPM, CrowdSec is a more natural fit. Run agents where the relevant logs live, use a central LAPI, and run a bouncer at VM1/NPM. Then the components are designed to exchange decisions instead of hoping two independent Fail2Ban installations telepathically coordinate.
Also: do not use set_real_ip_from 0.0.0.0/0 just to make X-Forwarded-For work. That turns a client-supplied header into an IP-spoofing API.
#SelfHosting #ReverseProxy #Fail2Ban #CrowdSec #NginxProxyManager



@nibbs
You’re welcome — happy tinkering. 🙂