Guides

fail2ban with Nginx Proxy Manager

Nginx Proxy Manager (NPM) writes its own log format to its own files inside a Docker volume, so stock nginx filters don't fit and a normal ban doesn't block anything. This guide fixes both with a tested filter and a DOCKER-USER jail.

Updated 6 min readChecked against fail2ban 1.1.1

To add fail2ban to Nginx Proxy Manager, run fail2ban on the Docker host, point a custom filter at NPM's logs in ./data/logs/proxy-host-*_access.log (NPM's /data/logs volume), match the [Client <IP>] field with status 401, 403 or 404, and ban with banaction = iptables-allports plus chain = DOCKER-USER. A ban in the default INPUT chain is ignored by traffic Docker forwards to the NPM container.

/etc/fail2ban/filter.d/npm-4xx.conf
[Definition]
failregex = ^\[\] \S+ \S+ (?:<codes>) - [A-Z]+ \S+ \S+ "[^"]*" \[Client <ADDR>\]
ignoreregex = ^\[\] \S+ \S+ \d+ - (?:GET|HEAD) \S+ \S+ "/(?:favicon\.ico|robots\.txt)"
datepattern = ^\[({DATE})

[Init]
codes = 401|403|404
/etc/fail2ban/jail.d/npm.local
[npm]
enabled   = true
backend   = auto
filter    = npm-4xx
logpath   = /opt/npm/data/logs/proxy-host-*_access.log
banaction = iptables-allports
chain     = DOCKER-USER
maxretry  = 10
findtime  = 10m
bantime   = 1h

Replace /opt/npm with the directory that holds your NPM compose.yaml (or docker-compose.yml).

Where Nginx Proxy Manager keeps its logs#

NPM's standard compose file mounts ./data to /data in the container:

compose.yaml
services:
  app:
    image: 'jc21/nginx-proxy-manager:latest'
    restart: unless-stopped
    ports:
      - '80:80'
      - '443:443'
      - '81:81'
    volumes:
      - ./data:/data
      - ./letsencrypt:/etc/letsencrypt

NPM writes one access and one error log per proxy host, numbered by the host's ID in the UI:

File (inside the container) Contents
/data/logs/proxy-host-1_access.log Requests to proxy host 1
/data/logs/proxy-host-1_error.log nginx errors for proxy host 1, incl. Access List auth failures
/data/logs/fallback_http_access.log Requests that matched no host
/data/logs/fallback_error.log Global nginx errors

On the host they're in ./data/logs/ next to your compose file. List them with ls /opt/npm/data/logs/. fail2ban expands proxy-host-*_access.log when the jail starts, so reload fail2ban after adding a proxy host in the NPM UI: sudo fail2ban-client reload npm.

NPM's log format#

NPM doesn't use nginx's combined format. Its proxy format, from NPM's log-proxy.conf, is:

nginx
log_format proxy '[$time_local] $upstream_cache_status $upstream_status $status - $request_method $scheme $host "$request_uri" [Client $remote_addr] [Length $body_bytes_sent] [Gzip $gzip_ratio] [Sent-to $server] "$http_user_agent" "$http_referer"';

A request looks like this:

log
[25/Oct/2026:18:36:10 +0000] - 404 404 - GET https cloud.example.com "/.env" [Client 203.0.113.42] [Length 146] [Gzip -] [Sent-to 172.18.0.5] "Mozilla/5.0" "-"
[25/Oct/2026:18:36:11 +0000] - - 401 - GET https app.example.com "/" [Client 203.0.113.42] [Length 179] [Gzip -] [Sent-to 172.18.0.6] "Mozilla/5.0" "-"

The fields before - are the cache status, the backend's status (- when NPM answered itself, for example an Access List 401) and the status sent to the client. The filter above counts the third one and takes the address from [Client …]. That's why stock filters such as nginx-bad-request don't match NPM's access logs: they expect the IP at the start of the line.

Test the filter#

bash
sudo fail2ban-regex /opt/npm/data/logs/proxy-host-1_access.log /etc/fail2ban/filter.d/npm-4xx.conf

We ran the filter against the sample lines above plus a 200, an IPv6 403 and a /favicon.ico 404: the 404, 401 and 403 lines matched, the favicon request was ignored and the 200 missed — that's what you want to see. To ban only 401/403 (failed logins) and leave 404s alone, use filter = npm-4xx[codes="401|403"]. Tweak and retest in the regex tester if you've customized NPM's log format.

Access List (basic auth) brute force#

Proxy hosts protected with an NPM Access List use nginx auth_basic. Failed logins land in the host's error log in the standard nginx format, so the stock nginx-http-auth filter works:

/etc/fail2ban/jail.d/npm.local
[npm-auth]
enabled   = true
backend   = auto
filter    = nginx-http-auth
logpath   = /opt/npm/data/logs/proxy-host-*_error.log
banaction = iptables-allports
chain     = DOCKER-USER
maxretry  = 5

Why chain = DOCKER-USER (and why INPUT doesn't work)#

Docker publishes NPM's ports 80, 81 and 443 with DNAT; packets are forwarded to the container and pass the FORWARD chain, never INPUT. fail2ban's default ban sits in INPUT (or the nftables input hook), so it shows up in fail2ban-client status but blocks nothing. DOCKER-USER is the chain Docker evaluates first for forwarded traffic. iptables-allports blocks every port, so you don't need to worry that the rule sees container ports after DNAT.

Make sure fail2ban starts after Docker (sudo systemctl edit fail2ban, add After=docker.service under [Unit]), and confirm the rule after the first ban:

bash
sudo iptables -L DOCKER-USER -n --line-numbers
sudo fail2ban-client status npm

If your host uses Docker's experimental nftables firewall backend, there's no DOCKER-USER chain; use the nftables forward-hook variant from fail2ban with Docker containers.

Run fail2ban on the host or in a container?#

On the host is simplest: apt install fail2ban, the logs are already on disk, and fail2ban manipulates the host firewall directly. In a container (crazymax/fail2ban, linuxserver/fail2ban) works too, but the container needs network_mode: host, the NET_ADMIN and NET_RAW capabilities, and NPM's data/logs mounted read-only, for example - /opt/npm/data/logs:/remotelogs/npm:ro; the logpath in the jail then uses the path inside the fail2ban container. Running fail2ban inside the NPM container itself isn't practical: it has no access to the host's firewall. See install fail2ban with Docker.

Unraid and Proxmox#

  • Unraid: NPM runs as a Docker app with its data under /mnt/user/appdata/. With the official image the logs are in the folder mapped to /data, subfolder logs; community images may use a different layout, so check the container's path mappings and look for proxy-host-*_access.log. Run fail2ban as a container with host networking and NET_ADMIN, as described below. If the container has its own IP on a custom br0 (macvlan) network, its traffic doesn't pass the host's FORWARD chain, so DOCKER-USER bans won't apply; use bridge networking with published ports, or ban at the router or at Cloudflare.
  • Proxmox: if NPM runs in an LXC container or VM, install fail2ban inside that guest, where NPM's logs and network stack are, and use the default INPUT-based ban unless NPM runs in Docker inside the guest. Protecting the Proxmox web UI itself is a separate jail: Proxmox guide.

Nginx Proxy Manager behind Cloudflare#

Two things change when Cloudflare proxies your domains:

  1. Real IP. NPM trusts Cloudflare's published ranges (it downloads them at startup unless IP_RANGES_FETCH_ENABLED=false) but reads the client address from the X-Real-IP header, which Cloudflare doesn't send. Without a fix, [Client …] shows Cloudflare addresses and fail2ban would ban Cloudflare. Create ./data/nginx/custom/server_proxy.conf — NPM includes it in every proxy host's server block — and restart NPM:
data/nginx/custom/server_proxy.conf
real_ip_header CF-Connecting-IP;
  1. Where to ban. Visitors connect to Cloudflare, and Cloudflare connects to you. A firewall ban on your host blocks Cloudflare's edge server, not the attacker, who simply keeps going through Cloudflare. Ban at Cloudflare's edge with the cloudflare-token action instead — set up in the Cloudflare guide.

FAQ#

Can fail2ban work with Nginx Proxy Manager?#

Yes. Run fail2ban on the Docker host, read NPM's logs from the data/logs volume with a filter that understands NPM's [Client <IP>] format, and ban with chain = DOCKER-USER and an iptables action so bans apply to traffic forwarded to the NPM container.

Where are the Nginx Proxy Manager logs?#

Inside the container in /data/logs/: proxy-host-<id>_access.log and proxy-host-<id>_error.log per proxy host, plus fallback_http_access.log and fallback_error.log. With the standard compose file they're in ./data/logs/ on the host, next to compose.yaml.

Why does fail2ban ban IPs but NPM still serves them?#

Because the ban is in the INPUT chain, and Docker forwards traffic to the NPM container through FORWARD. Set banaction = iptables-allports and chain = DOCKER-USER in the jail, reload fail2ban, and check iptables -L DOCKER-USER -n for the f2b- jump.

Which filter should I use for NPM?#

NPM's access logs use a custom format, so write a filter that matches [Client <ADDR>] and the status code, like npm-4xx on this page. For Access List (basic auth) failures, the stock nginx-http-auth filter works on the proxy-host-*_error.log files.

Do I need to reload fail2ban when I add a proxy host in NPM?#

Yes. fail2ban expands the proxy-host-*_access.log wildcard only when the jail starts, so a new host's log file isn't watched until you run sudo fail2ban-client reload npm. Existing files keep being followed across log rotation.

Spotted something wrong or out of date? Tell us — every page is checked against the current fail2ban release.