Configuring Fail2ban for a Podman Mail Server to Stop SASL Brute Force

Background

A self-hosted mail server (Postfix + Dovecot) runs on a netcup VPS in a Podman rootless container (the docker-mailserver image). One day, checking the Postfix log summary revealed that within 24 hours:

  • 2,477 emails rejected (99%), of which 2,471 were spam from a single IP
  • 152 SASL LOGIN authentication failures from over a hundred different IPs worldwide
  • Shodan scans, TLS probing, relay abuse attempts, etc.

Although Postfix’s built-in anti-spam rules blocked 99% of attacks at the door—no spam was actually delivered—SASL brute force is a real threat: given a weak enough password, it is only a matter of time before it gets cracked. fail2ban automatically bans IPs at the firewall after a configurable number of failed attempts, cutting off attacks at the source.

Environment

Item Value
Host OS Arch Linux
Container runtime Podman (rootless, user nite)
Mail server image ghcr.io/docker-mailserver/docker-mailserver:latest
Container name systemd-mailserver
Quadlet file ~/pod/docker-mailserver/mailserver.container
Mail log path (host) ~/pod/docker-mailserver/logs/mail.log
Mail log path (container) /var/log/mail/mail.log
fail2ban Installed via pacman -S fail2ban, version 1.1.0
Firewall UFW (does not interfere with fail2ban)

Key Question: Where Are the Logs?

fail2ban needs to read log files to detect attack patterns. In a traditional deployment, Postfix logs to /var/log/mail.log and fail2ban reads it directly. In a Podman container environment, there are two options:

  1. journald backend: If container logs go to journald, fail2ban can use backend = systemd to read from the journal
  2. polling backend: If the container writes logs to a mounted file, fail2ban uses backend = polling to read the file directly

The mail server container in this post writes logs to a volume-mounted file at ~/pod/docker-mailserver/logs/mail.log on the host, so the polling backend is the right choice.

Note: Arch Linux’s fail2ban sets postfix_backend = systemd by default via paths-arch.conf. If you don’t explicitly override this to polling in your jail config, fail2ban will read journald instead of the file and match nothing.

Installing fail2ban

sudo pacman -S fail2ban
sudo systemctl enable --now fail2ban

After installation, only the sshd jail is enabled by default. Confirm with sudo fail2ban-client status:

Status
|- Number of jail:	1
`- Jail list:	sshd

Creating the Jail Configuration

File path: /etc/fail2ban/jail.d/mailserver.local

Configuration content:

[DEFAULT]
postfix_backend = polling
postfix_log = /home/nite/pod/docker-mailserver/logs/mail.log

[postfix-sasl]
enabled   = true
mode      = auth
port      = smtp,465,submission,imap,imaps,pop3,pop3s
logpath   = /home/nite/pod/docker-mailserver/logs/mail.log
backend   = polling
maxretry  = 5
findtime  = 10m
bantime   = 1h
banaction = iptables-allports

[postfix]
enabled   = true
mode      = more
port      = smtp,465,submission
logpath   = /home/nite/pod/docker-mailserver/logs/mail.log
backend   = polling
maxretry  = 5
findtime  = 10m
bantime   = 1h
banaction = iptables-allports

Parameter Reference

Parameter Value Description
mode = auth postfix-sasl Matches SASL authentication failures (postfix[mode=auth] replaces the old postfix-sasl filter since fail2ban 0.10+)
mode = more postfix More aggressive than default normal mode; also matches relay abuse, unknown hosts, etc.
backend = polling both Polls the log file instead of using journald
maxretry = 5 5 failures within 10 minutes triggers a ban
findtime = 10m Counting window
bantime = 1h Ban duration
banaction = iptables-allports Ban method REJECTs all ports from the banned IP

postfix-sasl vs postfix[mode=auth]: Since fail2ban 0.10.0, the standalone postfix-sasl filter was removed and replaced with the mode=auth sub-mode of the postfix filter. In jail config, the [postfix-sasl] section still works because it internally calls filter = postfix[mode=auth] via the mode = auth parameter.

Restart and Verify

# Test configuration syntax
sudo fail2ban-client -t

# Restart
sudo systemctl restart fail2ban

# List all jails
sudo fail2ban-client status

Output should show three jails:

Status
|- Number of jail:	3
`- Jail list:	postfix, postfix-sasl, sshd

Check jail details:

sudo fail2ban-client status postfix-sasl
Status for the jail: postfix-sasl
|- Filter
|  |- Currently failed:	3
|  |- Total failed:	3
|  `- File list:	/home/nite/pod/docker-mailserver/logs/mail.log
`- Actions
   |- Currently banned:	0
   |- Total banned:	0
   `- Banned IP list:

File list correctly points to the container log path, and Currently failed > 0 means the filter is matching new attack log entries.

Verifying Filter Matches

To confirm the filter matches existing log entries (without actually banning anything), use fail2ban-regex:

# Test SASL auth failure matching
sudo fail2ban-regex /home/nite/pod/docker-mailserver/logs/mail.log "postfix[mode=auth]"

Example output:

Failregex: 586 total
|-  #) [# of hits] regular expression
|   1) [586] ^[^[]*\[<HOST>\](?::\d+)?: SASL ((?i)LOGIN|PLAIN|(?:CRAM|DIGEST)-MD5) authentication failed:...

Lines: 58500 lines, 0 ignored, 586 matched, 57914 missed

586 matches confirm the filter correctly identifies SASL brute-force log entries.

UFW Compatibility

If the server runs UFW as its firewall, fail2ban’s iptables-allports action still works fine. fail2ban inserts a jump rule at the head of the INPUT chain:

Chain INPUT (policy DROP)
num  target     prot opt source     destination
1    ts-input   all  --  0.0.0.0/0  0.0.0.0/0       # Tailscale
2    f2b-postfix-sasl  tcp  --  0.0.0.0/0  0.0.0.0/0  # fail2ban (auto-inserted)
3    ufw-before-logging-input ...

fail2ban’s rules execute before UFW’s rules, so banned IPs get REJECTed before reaching UFW. This does not interfere with existing firewall logic.

Confirming Bans Are Active

After some time passes (usually within minutes, an IP will hit the 5-failure threshold), check:

sudo fail2ban-client status postfix-sasl
Status for the jail: postfix-sasl
|- Filter
|  |- Currently failed:	8
|  |- Total failed:	47
|  `- File list:	/home/nite/pod/docker-mailserver/logs/mail.log
`- Actions
   |- Currently banned:	3
   |- Total banned:	12
   `- Banned IP list:	211.228.97.97 189.52.52.162 182.139.39.150

When IPs appear in Banned IP list, the ban is active. Check the fail2ban log to confirm:

sudo tail /var/log/fail2ban.log
... [postfix-sasl] Found 211.228.97.97 - 2026-07-09 07:01:40
... [postfix-sasl] Found 211.247.127.250 - 2026-07-09 07:01:57
... [postfix-sasl] Found 189.52.52.162 - 2026-07-09 07:02:10
... [postfix-sasl] Ban 211.228.97.97

Check the iptables ban chain:

sudo iptables -L f2b-postfix-sasl -n
Chain f2b-postfix-sasl (1 references)
target     prot opt source               destination
REJECT     all  --  211.228.97.97        0.0.0.0/0  reject-with icmp-port-unreachable
REJECT     all  --  189.52.52.162        0.0.0.0/0  reject-with icmp-port-unreachable

References