Before you start
fail2ban has been the standard host-based intrusion prevention tool on Linux for over twenty years. If you have a working fail2ban setup, it is doing exactly what it was designed to do. This guide exists to make the mechanical work of moving to fail2zig as low-risk as possible — not to argue you into it. Read it through once before touching anything, decide whether the tradeoffs make sense for your environment, and only then proceed.
What you’ll need
| Requirement | Notes |
|---|---|
| Linux kernel ≥ 3.13 | nftables (the default ban backend) needs ≥ 3.13; the epoll event loop and inotify log watcher are far older. io_uring is a planned backend, not required. |
| systemd | The unit file targets systemd. Non-systemd setups work but require manual service management. |
CAP_NET_ADMIN | The daemon needs this to write nftables rules. Running as root covers it. |
| nftables (preferred) | Available on any modern distribution. iptables and ipset are fallback backends. |
| The fail2zig binary | Build from source or download the prebuilt musl binary — see the install instructions. |
| A few minutes | The fast path (auto-import) takes under five minutes. |
/etc/fail2ban/ (optional) | If you have a non-default config location, note it for the import command. |
What does not transfer
fail2zig does not implement:
- sendmail / mail actions — actions that send email on ban events. Use an
external alerting path (e.g., a Prometheus alert on
fail2zig_bans_total). - Custom Python filter code — there is no Python runtime. Custom filters
written as Python classes must be rewritten as pattern strings in
fail2zig.tomlor contributed to the filter registry. - fail2ban’s SQLite state database — fail2zig uses its own binary state file
(
state.bin). Existing ban history does not carry over; the daemon starts with a clean state. - fail2ban’s socket protocol — the
fail2ban-clienttool cannot talk to fail2zig. Usefail2zig-clientinstead.
Everything else — jails, filters, ban thresholds, ignore lists, bantime increment
— transfers automatically for any of the 15 built-in filters, and via best-effort
translation for custom filter.d/*.conf files.
The fast path — auto-import
If your fail2ban config lives in /etc/fail2ban/, one command does most of the
work:
sudo fail2zig --import-config /etc/fail2ban \
--import-output /etc/fail2zig/config.tomlThe importer reads jail.conf + jail.local + jail.d/*.conf in fail2ban’s
precedence order, then attempts to resolve each enabled jail’s filter = value:
- Against the 15 built-in filters (exact name match, hyphen/underscore interchangeable). Built-in hits are zero-effort — no translation needed.
- Against
filter.d/<name>.confin your fail2ban tree. If the file contains translatablefailregexpatterns (no lookaheads, backreferences, or named conditionals), they are converted and the jail is marked viable. - If neither resolves, the jail is written to the output file in a disabled state with a warning explaining what couldn’t be translated.
Example: sshd + nginx + postfix
Given a fail2ban tree with these enabled jails in jail.local:
[sshd]
enabled = true
filter = sshd
logpath = /var/log/auth.log
maxretry = 3
bantime = 1h
[nginx-http-auth]
enabled = true
filter = nginx-http-auth
logpath = /var/log/nginx/error.log
[postfix]
enabled = true
filter = postfix
logpath = /var/log/mail.log
bantime = 2hRunning --import-config produces output like:
migration: imported=3 skipped=0 filters(translated=0 builtin=3 skipped=0) output='/etc/fail2zig/config.toml'And writes the following to /etc/fail2zig/config.toml:
# fail2zig configuration — generated by `fail2zig --import-config`.
# Edit freely; re-import will overwrite.
[global]
log_level = "info"
pid_file = "/run/fail2zig/fail2zig.pid"
socket_path = "/run/fail2zig/fail2zig.sock"
state_file = "/var/lib/fail2zig/state.bin"
memory_ceiling_mb = 64
metrics_bind = "127.0.0.1"
metrics_port = 9100
[defaults]
bantime = 600
findtime = 600
maxretry = 5
banaction = "nftables"
[jails.sshd]
enabled = true
filter = "sshd"
logpath = ["/var/log/auth.log"]
maxretry = 3
bantime = 3600
[jails.nginx-http-auth]
enabled = true
filter = "nginx-http-auth"
logpath = ["/var/log/nginx/error.log"]
[jails.postfix]
enabled = true
filter = "postfix"
logpath = ["/var/log/mail.log"]
bantime = 7200What gets auto-mapped vs. what needs manual review
| fail2ban construct | Auto-mapped? | Notes |
|---|---|---|
enabled, logpath, maxretry, findtime, bantime | Yes | Direct translation. |
ignoreip | Yes | CIDR notation is preserved. |
bantime.increment, bantime.factor, bantime.maxtime | Yes | Mapped to bantime_increment_* fields. |
banaction = iptables* | Yes | Mapped to banaction = "iptables". |
banaction = nftables* | Yes | Mapped to banaction = "nftables". |
banaction = ipset* | Yes | Mapped to banaction = "ipset". |
action.d/*.conf customizations | No | Different architecture — fail2zig bans via netlink, not shell templates. |
| Built-in filters (15) | Yes | Zero translation needed. |
Custom filter.d/*.conf | Best-effort | Simple failregex patterns without lookaheads or backreferences translate. Complex Python-side filters do not. |
sendmail / mail actions | No | Alerting is out of scope. Use Prometheus alerts instead. |
| SQLite ban history | No | fail2zig starts with empty state. Existing bans are not carried over. |
| Custom Python filter classes | No | No Python runtime. Must be rewritten as DSL patterns. |
Reviewing the generated config
Before starting fail2zig, review the generated TOML for:
-
Log paths — verify each
logpathentry matches where your distribution actually writes logs. Common divergences: Ubuntu uses/var/log/auth.log, RHEL uses/var/log/secure; Debian postfix writes to/var/log/mail.log, others to/var/log/maillog. -
Warnings in the migration report — each warning line describes a jail or filter that needed manual attention. A jail written to the file in disabled state (
enabled = false) will not watch logs or apply bans until you enable it and verify its filter. -
banaction— the importer defaults to"nftables"if the fail2ban action name isn’t a recognized prefix. Check that the selected backend is actually available on your system (sudo nft list tablesshould succeed).
Validate the generated config without starting the daemon:
sudo fail2zig --validate-config --config /etc/fail2zig/config.toml
# config: OK (3 jail(s) configured)The methodical path — hand-written config
If you want to understand each setting before committing to it, write the config from scratch. The full reference is at Configuration reference; this section walks through a minimal production config step by step.
[global] block
[global]
log_level = "info" # debug | info | warn | err
pid_file = "/run/fail2zig/fail2zig.pid"
socket_path = "/run/fail2zig/fail2zig.sock"
state_file = "/var/lib/fail2zig/state.bin"
memory_ceiling_mb = 64 # bounds the state tracker; min 16
metrics_bind = "127.0.0.1" # never expose this publicly
metrics_port = 9100memory_ceiling_mb is the most important global setting for operators coming
from fail2ban. It bounds the state tracker — the dominant consumer — via a
fixed, ceiling-derived entry cap, so attack volume cannot grow the daemon past
it (when full, it evicts the oldest un-banned record). The default of 64 MB is
generous for most deployments; you can drop it to 32 MB on servers with tight
memory constraints.
[defaults] block
[defaults]
bantime = 600 # seconds; how long a first-time offender is banned
findtime = 600 # sliding window (seconds) in which maxretry must occur
maxretry = 5 # failures within findtime before a ban fires
banaction = "nftables" # nftables | iptables | ipset | log-only
ignoreip = ["127.0.0.1/8", "::1"] # never ban these (CIDR and exact IPs)All [defaults] values apply to every jail unless overridden. If your
fail2ban jail.conf had a [DEFAULT] section with bantime = 3600, set
that here.
[jails.<name>] block
[jails.sshd]
enabled = true
filter = "sshd"
logpath = ["/var/log/auth.log", "/var/log/secure"] # both common locations
maxretry = 3 # override defaults.maxretry for this jail
bantime = 3600 # override defaults.bantime for this jail
# Bantime increment: bans get longer on each re-offense.
bantime_increment_enabled = true
bantime_increment_formula = "exponential" # linear | exponential
bantime_increment_multiplier = 2
bantime_increment_max_bantime = 604800 # 1 week capThe filter value must be one of the 15 built-in filter names — an unknown
filter on an enabled jail makes the daemon fail closed (refuse to start). See
the Filters reference for the full list and per-filter
log path guidance.
Cross-reference: fail2ban → fail2zig
A direct map between fail2ban’s jail.conf keys and fail2zig’s TOML
equivalents. Most values carry over one-to-one; the most common
footgun is ignoreip (whitespace-separated in fail2ban, string array
in fail2zig) and time values (fail2ban accepts 10m / 1h suffixes,
fail2zig uses integer seconds).
| fail2ban key | fail2zig equivalent | Notes |
|---|---|---|
[DEFAULT] section | [defaults] section | Direct equivalent. |
bantime | bantime | Same semantics. fail2ban accepts 10m, 1h suffixes; fail2zig uses integer seconds only. |
findtime | findtime | Same semantics. Integer seconds only. |
maxretry | maxretry | Same semantics. |
ignoreip | ignoreip | TOML string array instead of whitespace-separated string. |
banaction | banaction | Mapped to one of: nftables, iptables, ipset, log-only. |
bantime.increment | bantime_increment_enabled | |
bantime.factor | bantime_increment_factor | |
bantime.multiplier | bantime_increment_multiplier | |
bantime.maxtime | bantime_increment_max_bantime | |
bantime.formula | bantime_increment_formula | Values: linear, exponential. |
logpath | logpath | TOML string array. |
filter | filter | Same filter name convention. |
enabled | enabled | |
backend | source | fail2ban’s backend maps to fail2zig’s per-jail source = auto | file | journald. auto reads journald when no log file exists (sshd). |
For full type / default / constraint detail on the fail2zig side, see the Configuration reference.
Running side-by-side during verification
The safest migration runs fail2ban and fail2zig simultaneously for a short period — fail2ban handling bans as usual while fail2zig watches logs and you verify it would have made the same decisions.
Step 1: Disable fail2ban’s nftables output (optional)
If both daemons try to write to the inet fail2zig table, they’ll conflict.
The simplest approach is to keep fail2ban running in its current mode
(writing to its own nftables table or using iptables) while you start
fail2zig in log-only mode:
[defaults]
banaction = "log-only" # fail2zig logs ban decisions but doesn't touch nftablesThis lets you watch journalctl -u fail2zig -f and see ban: lines appearing
for the same IPs fail2ban would catch, without either daemon interfering with
the other’s rules.
Step 2: Start fail2zig
sudo systemctl start fail2zig
sudo systemctl status fail2zigIf you are not running under the systemd unit (it is installed by scripts/install.sh), start it in the foreground directly:
sudo fail2zig --foreground --config /etc/fail2zig/config.tomlStep 3: Watch both daemons
In separate terminals:
# fail2ban decisions
sudo journalctl -u fail2ban -f | grep -i "ban\|unban"
# fail2zig decisions
sudo journalctl -u fail2zig -f | grep -i "ban\|unban"Run for 10–30 minutes of normal traffic. The two daemons should make identical ban decisions for the same source IPs on the same log lines.
Cut-over day
When you’re confident fail2zig is matching correctly:
1. Stop fail2ban
sudo systemctl stop fail2ban
sudo systemctl disable fail2ban2. If fail2zig is running in log-only mode, switch it to nftables
Edit /etc/fail2zig/config.toml, set banaction = "nftables" in [defaults],
then reload:
sudo systemctl restart fail2zig3. Verify bans are enforced
Generate a test ban event (or wait for natural traffic), then verify the kernel set is populated:
# Query via the client
sudo fail2zig-client list
# Verify at the kernel level
sudo nft list set inet fail2zig banned_ipv4
# table inet fail2zig {
# set banned_ipv4 {
# type ipv4_addr
# flags timeout
# elements = { 45.227.253.98 timeout 5m expires 4m43s }
# }
# }4. Enable fail2zig at startup
sudo systemctl enable fail2zigRollback plan
If you need to go back to fail2ban:
# Stop fail2zig
sudo systemctl stop fail2zig
sudo systemctl disable fail2zig
# Clean up the nftables table fail2zig installed
sudo nft delete table inet fail2zig
# Restart fail2ban
sudo systemctl start fail2ban
sudo systemctl enable fail2ban
# Verify fail2ban is back in control
sudo fail2ban-client statusfail2zig’s config and binary are untouched by this procedure. The daemon’s
state file at /var/lib/fail2zig/state.bin is also untouched — if you
decide to try again later, previously recorded ban state will be restored.
Common gotchas
Log routing via rsyslog
On systems where rsyslog routes logs to non-standard paths, the logpath
in your jail may need to match rsyslog’s output rather than the daemon’s
own log file. Check rsyslog.conf and /etc/rsyslog.d/ for any custom
local0.* or auth.* routing rules.
SELinux contexts
On RHEL/CentOS/Fedora, SELinux may block fail2zig from reading log files
that aren’t labeled var_log_t. If you see permission errors in the daemon
log despite running as root, check:
sudo ausearch -c fail2zig | tail -20You can relabel log files or add a local policy. The binary itself doesn’t
need any custom policy beyond what’s granted by its capabilities
(CAP_NET_ADMIN, CAP_DAC_READ_SEARCH).
ignoreip CIDR syntax differences
fail2ban accepts whitespace-separated ignore lists in jail.conf:
ignoreip = 127.0.0.1/8 10.0.0.0/8 ::1fail2zig expects a TOML string array:
ignoreip = ["127.0.0.1/8", "10.0.0.0/8", "::1"]The importer handles this conversion automatically, but if you hand-write the config, use the TOML array form.
IPv4-mapped IPv6 addresses
fail2zig canonicalizes ::ffff:a.b.c.d addresses to their IPv4 form at
the parse boundary. If your ignore list contains ::ffff:127.0.0.1, it
is treated identically to 127.0.0.1. This was an explicit security fix
(SEC-001) to prevent ban evasion via address-family switching.
bantime.multipliers (plural)
fail2ban’s bantime.multipliers key (a custom progression list) is not
supported. The importer emits a warning and falls back to the linear
multiplier formula. If you relied on a custom progression, translate it to
an exponential formula with an appropriate bantime_increment_factor.