- The Problem with “Traditional” fail2ban in Docker
- Step 1: Let OPNsense Handle the Blocking
- Step 2: Notifications with ntfy
- Step 3: Mail Server in Docker (Postfix + Dovecot)
- Why I prefer this
- The Imperfection: Internal Attackers
- Final Thoughts
Running services in Docker is easy. Running them securely, observably, and without punching unnecessary holes into your host? That’s where things get interesting.
For years, I ran fail2ban the traditional way: inside Docker, with access to the host network stack and enough capabilities to manipulate iptables. It worked. But it never felt right.
This post documents how I moved fail2ban enforcement to the edge — onto my opnsense firewall — and why that decision simplified my setup considerably.
The Problem with “Traditional” fail2ban in Docker
The usual Docker pattern looks like this:
- fail2ban container
--network=hostNET_ADMINcapability- access to
/var/log - permission to manipulate host firewall rules
That means:
- Direct access to the host networking stack
- Permission to modify
iptables - Elevated privileges inside the container
Even if you’re comfortable with Docker hardening, this setup feels like breaking containment on purpose.
It works. But it defeats part of the isolation benefit of containers.
Step 1: Let OPNsense Handle the Blocking
OPNsense provides a well-documented API and supports dynamic alias tables.
Instead of fail2ban running:
iptables -I INPUT -s <ip> -j DROP
I use a custom action that:
- Calls the OPNsense API
- Adds the IP to a dedicated alias
- Applies the change
- Removes the IP again on unban
OPNsense Setup
Create an alias:
- Type: Host(s)
- Name:
fail2ban_blocklist
Add a WAN rule:
- Source:
fail2ban_blocklist - Action: Block
Now the firewall enforces bans globally.
API User Permissions
Create a dedicated API user in OPNsense for fail2ban. This user needs at least the “Diagnostics: PF Table IP addresses” privilege. Otherwise, access to the alias_utils API endpoint is not allowed.
The exact permission names may vary slightly by version.
Use the button in the overview to download the API key and store for later use in fail2ban.
fail2ban Action for OPNsense
You can e.g., use the opnsense action defined in PR #2761. Use the action like so:
banaction = opnsense[firewall="192.168.123.1",key="top_key",secret="top_secret", alias="fail2ban_blocklist"]
Detection stays local. Enforcement moves to the edge.
Step 2: Notifications with ntfy
Blocking attackers is nice.
Knowing when it happens is better.
I use a local ntfy.sh instance for push notifications.
When a ban/unban happens:
- fail2ban triggers ntfy action
- I receive instant push notification
I found the action already defined in PR #4099. Use it like this:
action = %(action_)s
ntfy[ntfyurl="https://ntfy.domain.com",ntfytopic="intruter_alerts",ntfytoken="secret",ntfypriority="max"]
as a reminder, you can generate a token for user “user1” e.g., like this:
docker exec -it ntfy_container ntfy token add user1
Step 3: Mail Server in Docker (Postfix + Dovecot)
I run Dovecot & Postfix inside Docker.
They sit behind Traefik, which acts purely as a TCP forwarder.
TLS termination happens directly in Postfix and Dovecot.
This keeps:
- Certificate handling inside the mail stack (Let’s Encrypt is handled by Traefik and certs are exported).
- STARTTLS and wrapper mode native
- No protocol interference at the proxy layer
Traefik TCP Entrypoints
We use dedicated TCP entrypoints for secure mail:
- SMTPS (465)
- IMAPS (993)
Example:
entryPoints:
smtps:
address: ":465"
proxyProtocol:
trustedIPs:
- "172.18.0.0/16"
imaps:
address: ":993"
proxyProtocol:
trustedIPs:
- "172.18.0.0/16"
Traefik forwards raw TCP connections and injects Proxy Protocol headers with original sender IP. Note: NAT at opnsense is not an issue here, as NAT preserves sender IP.
You also want to configure the Dovecot/Postfix containers for Traefik to know how to handle them, e.g.,
# Dovecot
labels
- "traefik.enable=true"
- "traefik.tcp.routers.dovecot-imaps.rule=HostSNI(`*`)"
- "traefik.tcp.routers.dovecot-imaps.entrypoints=imaps"
- "traefik.tcp.routers.dovecot-imaps.service=dovecot-imaps"
- "traefik.tcp.routers.dovecot-imaps.tls.passthrough=true"
- "traefik.tcp.services.dovecot-imaps.loadbalancer.server.port=993"
- "traefik.tcp.services.dovecot-imaps.loadbalancer.proxyProtocol.version=1"
- "traefik.http.routers.dovecot-imaps.middlewares="
# Postfix
labels
- "traefik.enable=true"
- "traefik.tcp.routers.postfix-smtps.rule=HostSNI(`*`)"
- "traefik.tcp.routers.postfix-smtps.entrypoints=smtps"
- "traefik.tcp.routers.postfix-smtps.service=postfix-smtps"
- "traefik.tcp.routers.postfix-smtps.tls.passthrough=true"
- "traefik.tcp.services.postfix-smtps.loadbalancer.server.port=465"
- "traefik.tcp.services.postfix-smtps.loadbalancer.proxyProtocol.version=1"
- "traefik.http.routers.postfix-smtps.middlewares="
Postfix Configuration
main.cf
smtpd_upstream_proxy_protocol = haproxy
smtpd_upstream_proxy_timeout = 5s
master.cf (SMTPS example)
smtps inet n - n - - smtpd
-o syslog_name=postfix/smtps
-o smtpd_tls_wrappermode=yes
-o smtpd_upstream_proxy_protocol=haproxy
-o smtpd_upstream_proxy_timeout=5s
Dovecot Configuration
In 10-master.conf (or equivalent), inside the IMAPS login service block:
service imap-login {
inet_listener imaps {
port = 993
ssl = yes
haproxy = yes
}
}
Key point:
haproxy = yesmust be defined inside theinet_listenerblock for the specific service (IMAPS, IMAP, etc.).- This tells Dovecot to expect a Proxy Protocol header on that listener.
Without this, Dovecot will log the Docker IP or reject the connection.
Resulting Logs (example)
Without Proxy Protocol:
imap-login: Info: Logged in: user=<user1>, method=PLAIN, rip=172.18.0.1, lip=172.18.0.23, TLS, session=<jQQuO/tL5KWsEwAB>
With Proxy Protocol enabled:
imap-login: Info: Logged in: user=<user1>, method=PLAIN, rip=123.123.123.123, lip=172.18.0.23, TLS, session=<ID>
Now fail2ban sees the real client IP and can ban it correctly — which then gets enforced at the firewall. This is of course also necessary for local banning, not just on the edge.
Why I prefer this
1. Principle of Least Privilege
fail2ban:
- No NET_ADMIN
- No host network
- No iptables access
- Portable in cluster scenarios!
Containers remain isolated.
2. Centralized Enforcement
Blocking happens at the edge.
- Applies to all exposed services
- Stops traffic before it reaches Docker
- Cleaner policy model
3. Cleaner Separation of Concerns
- Application detects
- Firewall enforces
Simple and predictable.
The Imperfection: Internal Attackers
This setup is not perfect.
If an attacker is:
- An infected smartphone
- A compromised IoT device
- A laptop inside your LAN
And everything is on the same flat network (no VLANs, no segmentation), then:
- fail2ban detects abuse
- The IP is added to the firewall alias
- But internal traffic may bypass WAN filtering entirely
Edge enforcement protects you from the internet.
It does not automatically protect you from your own network.
The real solution:
- VLAN segmentation
- Inter-VLAN firewall rules
- Restrictive east-west traffic policies
Edge-based banning is powerful — but it is not a substitute for internal network architecture.
Final Thoughts
Moving fail2ban enforcement to opnsense significantly significantly cleans the dependencies and feels much cleaner. Note that latency is no issue at all with banning. Detection times (retries, etc.) are far longer than banning latency. It’s not perfect. But it is cleaner, more secure, and far easier to reason about than letting a container manipulate your host firewall directly.