Skip to main content

Installing Fail2Ban

Two images: the API, and an optional console. The API answers on 5421, the console on 5423.

Deploying it​

You need a Linux host with Docker and the Compose plugin, and an API key of your own choosing (any long random string, for example from openssl rand -base64 32).

Save this as docker-compose.yml:

services:
fail2ban:
image: ghcr.io/antelao-io/fail2ban:1.5.1
restart: unless-stopped
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
environment:
ASPNETCORE_URLS: http://*:5421
ANTELAO_HEALTH_URL: http://127.0.0.1:5421/health
Api__Keys__ui: ${FAIL2BAN_API_KEY:?set FAIL2BAN_API_KEY}
FAIL2BAN_SEED_ADMIN_PASS: ${FAIL2BAN_SEED_ADMIN_PASS:-}
FAIL2BAN_SEED_ADMIN_TOTP: ${FAIL2BAN_SEED_ADMIN_TOTP:-}
volumes:
- fail2ban-config:/config
- /etc/machine-id:/etc/machine-id:ro
- /var/log:/var/log:ro

fail2ban-ui:
image: ghcr.io/antelao-io/fail2ban-ui:1.5.1
restart: unless-stopped
profiles: [console]
ports:
- "127.0.0.1:5423:8080"
environment:
Fail2BanApi__BaseUrl: http://host.docker.internal:5421
Fail2BanApi__ApiKey: ${FAIL2BAN_API_KEY:?set FAIL2BAN_API_KEY}
DataProtection__KeysPath: /keys
extra_hosts:
- "host.docker.internal:host-gateway"
volumes:
- fail2ban-ui-keys:/keys

volumes:
fail2ban-config:
fail2ban-ui-keys:

Put the values in a .env file beside it:

FAIL2BAN_API_KEY=<your API key>
FAIL2BAN_SEED_ADMIN_PASS=<the console administrator's password>
FAIL2BAN_SEED_ADMIN_TOTP=<optional: the administrator's authenticator secret>

Then start the API alone, or the API and the console:

sudo docker compose up -d
sudo docker compose --profile console up -d
  • The API is not published, it is bound. Host networking means it listens on the host's own 5421, on every address of the host in this example, over plain HTTP. Host networking is also what puts its rules on the host's firewall rather than in a namespace of its own. Decide who may reach it before you start it: see Keeping the API key off the network.
  • The console is published on loopback only. It is a sign-in page for people, not an API for machines. Reach it over an SSH tunnel (http://localhost:5423), or put a reverse proxy with HTTPS in front of it - passkeys work only over HTTPS, and the console keeps a session only over HTTPS or on localhost. Behind a proxy, see The console behind a reverse proxy. Sign in as admin with FAIL2BAN_SEED_ADMIN_PASS, and a code from the authenticator app when FAIL2BAN_SEED_ADMIN_TOTP is set, then add accounts for everyone else on the Users page; see Accounts.
  • The console's key is named ui. The API believes the browser's address, for the audit log and the session list, only from the key the console calls with, which it recognises by the names ui and console. Name it something else and set Api__ConsoleKeyName to that name, or every sign-in is recorded with the console's own address.
  • The version is pinned. The example names release 1.5.1, and both images carry the same number. Keep a release number there rather than latest, which moves the moment a new release is published, so that you can always say what is deployed. The current releases, and what each one changed, are on Release notes. The images live in the registry under those numbers as their tags, which a registry tool lists, for example skopeo list-tags docker://ghcr.io/antelao-io/fail2ban; the console's Release notes page says what changed in the build you run. To upgrade, change the number on both images and run sudo docker compose pull and the up -d command again. Check the image's signature too: see Verify a download.
  • Keep the fail2ban-config volume across an upgrade. It holds the service's database - the jails, the settings, the accounts - and the ban database and the license. Losing it drops every jail, every account and every ban in force.
  • /etc/machine-id is mounted because the license is bound to the host, not the container. Without it every recreated container looks like a different machine.

The settings the API container reads are listed in the reference. The one to decide on day one is the firewall chain: see The firewall.

Two things it cannot work without​

Without NET_ADMIN, NET_RAW and host networking it blocks nothing

It starts. It reports itself healthy. It writes perfect jails. And no packet is ever dropped.

That is the single worst way this can be deployed, because every signal you would normally trust says it is working. Check the host the first time:

sudo iptables -S | grep f2b

Chains named f2b-<jail> mean the daemon is really installing rules. Nothing means you have a very convincing no-op.

The logs the jails watch have to be mounted

Read-only, at the path the jail names. A jail whose logpath is not there matches nothing, for ever, and says so nowhere except fail2ban's own log.

The compose file above mounts /var/log; narrow it to the directories that are actually watched. The mounted folders are also the only ones a jail may read: see Where a jail may read.

Those two are the same class of failure: everything reports success and no protection exists. They are worth five minutes on the day you deploy.

Keeping the API key off the network​

The API answers over plain HTTP, and every call carries the key

The API has no TLS of its own. Every call sends the API key in the X-Api-Key header, so on a network you do not control anyone who can see the traffic can read the key and use it.

A key that is not read-only can lift bans, change jails and take a backup that holds every secret.

ASPNETCORE_URLS: http://*:5421 in the example listens on every address of the host. Narrow it to what the callers need:

Who calls the APIWhat to do
Only programs on this hostListen on loopback, as below
Machines on a private networkAdmit only the callers, and the console's Docker network, to port 5421 in the host firewall. Because the API uses host networking, the host's ordinary INPUT rules apply to it
Machines across the internetPut a reverse proxy with HTTPS in front of it on this host, listen on loopback as below, and name the proxy as trusted

To listen on loopback, change the API's address. The console as shown reaches the API through Docker's host gateway, which a loopback address does not answer, so give the console host networking as well and point it at loopback:

services:
fail2ban:
environment:
ASPNETCORE_URLS: http://127.0.0.1:5421
ANTELAO_HEALTH_URL: http://127.0.0.1:5421/health

fail2ban-ui:
network_mode: host
environment:
ASPNETCORE_URLS: http://127.0.0.1:5423
ANTELAO_HEALTH_URL: http://127.0.0.1:5423/health
Fail2BanApi__BaseUrl: http://127.0.0.1:5421

Remove the console's ports and extra_hosts lines, which host networking does not use. The console is then reached on http://localhost:5423 over an SSH tunnel, as before. A reverse proxy on the same host now reaches both over loopback, so name it as Api__TrustedProxies__0: 127.0.0.1 on the API and Ui__TrustedProxies__0: 127.0.0.1 on the console.

The console behind a reverse proxy​

The console listens on plain HTTP and sees every request as coming from the proxy. Name the proxy, by address or network, so its X-Forwarded-For and X-Forwarded-Proto are believed:

environment:
Ui__TrustedProxies__0: 172.18.0.1

Nobody else's are: with none named, the console takes the connection as it is - right for an SSH tunnel, wrong behind a proxy, where every sign-in would be counted and recorded as the proxy's address. For a proxy on the same host, the address is the gateway of the Docker network the console is on (docker network inspect <network>).

Every answer from the console and the API carries headers that stop another site framing the pages or the browser second-guessing what they are; behind HTTPS the console asks the browser to keep to HTTPS as well.

A proxy in front of the API itself is named the same way, on the API's container, with Api__TrustedProxies__0.

A proxy that is not trusted​

When a proxy on the host's own networks (loopback, or a private address) forwards requests to the console or the API without being named as a trusted proxy, every visitor behind it is judged as the proxy: they share one sign-in limit, and the audit log and the session list show the proxy's address instead of theirs. The console then shows Administrators a notice on every page:

  • which address forwards, whether the console or the API saw it, how many requests, and when it was first and last seen;
  • what to set: Ui__TrustedProxies__0 on the console, or Api__TrustedProxies__0 on the API, with the address it names. Restart that container afterwards;
  • red when the address is loopback (127.0.0.1 or ::1): that is a sidecar or a proxy in the same network namespace, for example peer encryption on Azure Container Apps. Amber for any other private address, which may also be your own forward proxy; trusting it silences the notice.

The notice has no close button. It goes away 24 hours after the proxy was last seen, so once the proxy is trusted it lapses on its own.

The first time a loopback proxy is seen, the service also sends one mail to the recipients on the SMTP page. While the mail cannot be handed to the relay (no SMTP configured, or the relay refuses it), it is tried again every 15 minutes, and the notice says so. Once it is handed over it is never sent again, also after a restart.

A request through an SSH tunnel carries no forwarded address, so it never raises the notice.

Cloudflare Turnstile​

Cloudflare Turnstile confirms that a sign-in comes from a person's browser - it weighs the browser and how it behaves, usually without a puzzle - before the password is even looked at. The console's sign-in page can carry it, so a password or passkey sign-in is accepted only from a browser that passed it. It is off until both of its keys are set, and a console with no internet access leaves it off.

  1. In the Cloudflare dashboard, add a Turnstile widget for the hostname people open the console on, and copy its site key and secret key.
  2. Add them to the console's environment in the compose file, and put the values in .env as FAIL2BAN_TURNSTILE_SITE_KEY, FAIL2BAN_TURNSTILE_SECRET_KEY and, optionally, FAIL2BAN_TURNSTILE_HOSTNAME:
fail2ban-ui:
environment:
Turnstile__SiteKey: ${FAIL2BAN_TURNSTILE_SITE_KEY:-}
Turnstile__SecretKey: ${FAIL2BAN_TURNSTILE_SECRET_KEY:-}
Turnstile__Hostnames__0: ${FAIL2BAN_TURNSTILE_HOSTNAME:-}
Turnstile__Bypass: ${FAIL2BAN_TURNSTILE_BYPASS:-false}

Turnstile__Hostnames__0 is optional: when it is set (for example to fail2ban.example.com), an answer solved on any other hostname is refused.

The console verifies every answer with Cloudflare, so it must reach challenges.cloudflare.com on port 443. The check fails closed: when Cloudflare cannot be reached, or a key is wrong, nobody can sign in. A refused check counts against the console's sign-in throttle like any other attempt.

Locked out by the check

Start the console with Turnstile__Bypass=true (with the lines above, FAIL2BAN_TURNSTILE_BYPASS=true in .env), or with the start argument --Turnstile:Bypass=true (in compose, command: ["--Turnstile:Bypass=true"] on the console). The check is then off although the keys are set: the console warns about it at every start, and the audit log notes it on every sign-in. Remove it once the cause is fixed.

Proving a ban actually works​

A ban being listed is not a ban being enforced. See Bans, and whether they worked for the difference and how to check the counters, which is also the trap behind reverse proxies.