default_policy: deny means no rule = 403 even for logged-in users. Step 4 now explains the two-sided requirement: Caddy controls what traffic reaches Authelia; the rule controls what Authelia does with it. https://claude.ai/code/session_012eTokAaGiZo7aGt1T2W9BC
Authelia + fail2ban
Self-hosted authentication portal (Authelia) with an IP-banning sidecar
(fail2ban). Sits next to your dockerized Caddy and gates every public
subdomain behind a single sign-on portal at auth.example.com.
Internet
|
v
+-------+ caddy_net (docker) +--------------------+
| Caddy |--- forward_auth -------------->| Authelia |
+---+---+ | /api/authz/... |
| reverse_proxy +--------+-----------+
| |
v v
Frigate (LAN), NAS, Pi, etc. ./authelia/db.sqlite3
./authelia/authelia.log
^
| tail
+------+--------+
| fail2ban | host net
| DOCKER-USER | + iptables
+---------------+
- One docker-compose file, two services, one external network (
caddy_net). - File-backed users database, SQLite storage, no Redis, no external DB.
- Filesystem notifier for password reset (swap to SMTP later, one block change).
- fail2ban bans via the
DOCKER-USERiptables chain: drops happen at the host edge before traffic reaches any docker-published port. - Caddy is not in this stack. Copy blocks from
caddy/snippets.caddyfileinto your Caddy setup.
Repo layout
authelia-stack/
├── docker-compose.yml
├── .env.example # copy to .env
├── .gitignore
├── README.md
│
├── authelia/
│ ├── configuration.yml # main config -- edit your domain here
│ ├── users_database.yml.example # copy to users_database.yml (gitignored)
│ ├── secrets/ # gitignored; secret files mounted as /secrets
│ └── notifications/ # filesystem notifier writes here (gitignored)
│
├── fail2ban/
│ └── data/ # mounted as /data in the container
│ ├── filter.d/
│ │ ├── authelia.local # matches Authelia text-log auth failures
│ │ └── caddy-4xx.local # matches Caddy JSON 4xx responses
│ └── jail.d/
│ ├── authelia.local # 3 fails/10 min -> 1 hr IP ban
│ └── caddy.local # 30 fails/2 min -> 30 min IP ban
│
└── caddy/
└── snippets.caddyfile # per-service snippets to add to your existing Caddyfile
Prerequisites
- Docker + docker compose v2.
- Caddy already running, in Docker, joined to an external network named
caddy_net. If your network is named differently, replacecaddy_neteverywhere in this repo. - Caddy v2.5.1 or newer (for the
forward_authdirective; tested on v2.11.2). - A root domain you control. DNS A records for
auth.<root>and every protected subdomain must point at the Caddy host's public IP.
Which sites go behind Authelia?
There are four ways a site can relate to Authelia. Pick one per site.
| Case | App has built-in auth? | Supports proxy auth? | What to do |
|---|---|---|---|
| 1 | No | n/a | import authelia in Caddy + rule in Authelia. Authelia is the only login. |
| 2a | Yes | Yes (Remote-User header) | import authelia + disable app's own login form. Single login via headers. |
| 2b | Yes | Yes (OIDC) | import authelia + configure Authelia as OIDC provider in the app. Single login via token exchange. |
| 3 | Yes | No | import authelia in Caddy. App auth is unchanged. User logs into Authelia then the app. Two logins. |
| 4 | Yes | — | Plain reverse_proxy. No import authelia, no rule. App handles auth. |
Concretely:
doorbell.example.com(Pi PTT page) -- case 1. No app auth at all. Authelia is the only gate. Usetwo_factor-- this URL controls a speaker.cam.example.com(Frigate UI) -- case 2a. Frigate 0.14+ supports proxy auth viaRemote-Userheader. Disable Frigate's login form and let Authelia drive both the access gate and the role mapping (admin vs. viewer).books.example.com(Audiobookshelf) -- case 2b. App redirects to Authelia, Authelia issues a JWT token, app accepts it. No password set in the app itself.- Router admin / NAS UI -- case 3 if you want a 2FA gate in front, case 4 if you just leave it to the app.
What is OIDC?
OpenID Connect (OIDC) is an identity protocol layered on top of OAuth 2.0. The short version: instead of an app checking your password itself, it redirects you to Authelia, Authelia authenticates you and issues a signed token (JWT), and the app trusts that token. The app never handles your password — it only ever sees the token.
Authelia becomes the identity provider (IdP). Apps like Audiobookshelf, Immich, Jellyfin, and Mealie become relying parties — they trust Authelia's tokens and use them to identify users.
The practical difference from proxy-header auth (case 2a):
- Headers: Caddy adds
Remote-Userto every request and the app reads it. Works silently. Requires the app to support header-based auth. - OIDC: The browser does a full redirect dance (app → Authelia → app). Users see the Authelia login page. Requires the app to support OIDC/OAuth.
Both result in the same thing: one Authelia credential covers the app.
OIDC requires additional setup in authelia/configuration.yml —
an identity_providers.oidc block with a client entry per app, each with
its own client_id and client_secret. See Authelia's OIDC docs for the
full config. The Caddy side is identical to case 2a: import authelia.
Default policy in configuration.yml is deny, so a domain with no rule
AND no import authelia in Caddy never reaches Authelia at all.
Should you use Authelia at all?
A password manager with per-service credentials and per-service TOTP is a solid security posture. Authelia improves on it in specific situations:
| Situation | Password manager alone | Authelia |
|---|---|---|
| App has no auth at all (Homer, Dozzle, doorbell page) | Can't help | Gates it with 2FA, zero app changes |
| App has auth but no native TOTP (Uptime Kuma, Syncthing, phpIPAM) | Password-only | Adds 2FA in front for free |
| Multiple people need access | Change credentials in N places | Disable one account in Authelia |
| True SSO -- log in once, reach 10 services | Still authenticates 10× (autofilled) | One session covers all gated services |
| Consistent rate-limiting / brute-force protection | Wildly varies per app | fail2ban + regulation applied uniformly |
Where Authelia is marginal for a single user:
- All your services already have native TOTP support → Authelia adds mostly friction. You're right that 2FA doesn't require Authelia: Vaultwarden, Nextcloud, Grafana, and Gitea all support TOTP natively. If you've already set that up in a manager like Bitwarden, Authelia's 2FA argument is weaker.
- The Authelia password itself becomes keys-to-the-kingdom for everything gated behind it, which is why the TOTP requirement on Authelia matters more than on any individual service.
The real sweet spot:
- You have services with zero auth (case 1: Homer, Prometheus, NUT web UI, Gatus) -- something has to gate them.
- You have services with auth but no TOTP -- Authelia gives them 2FA without touching the app at all.
- You manage access for more than one person.
How to tell if an app supports proxy auth (case 2)
Look for any of these in the app's docs: "Remote-User header", "trusted upstream", "trusted proxies", "header-based auth", "SSO via reverse proxy".
| App | Case | Notes |
|---|---|---|
| Proxy-header auth (case 2a) | ||
| Frigate 0.14+ | 2a | auth.enabled: False + proxy: block in config.yml |
| Grafana | 2a | [auth.proxy] in grafana.ini; GF_AUTH_PROXY_ENABLED=true |
| Gitea / Forgejo | 2a | ENABLE_REVERSE_PROXY_AUTHENTICATION=true in app.ini |
| Nextcloud | 2a | trusted_proxies + user_external app + HTTP header auth |
| Paperless-ngx | 2a | PAPERLESS_ENABLE_HTTP_REMOTE_USER=true |
| Miniflux | 2a | AUTH_PROXY_HEADER=Remote-User env var |
| Home Assistant | 2a | trusted_networks auth provider + header forwarding |
| BookStack | 2a | AUTH_METHOD=http + HTTP_AUTH_HEADER=Remote-User in .env |
| OIDC auth (case 2b) | ||
| Audiobookshelf | 2b | Native OIDC; configure in Settings > Authentication |
| Jellyfin | 2b | Requires Jellyfin.Plugin.SSO from Plugin Catalogue |
| Immich | 2b | No header auth; OIDC only. Admin > OAuth settings |
| Mealie | 2b | OIDC supported; or use case 3 as a simpler gate |
| Portainer | 2b | OIDC in Settings > Authentication; or case 3 as simple gate |
| No built-in auth (case 1) | ||
| Homer / Heimdall | 1 | No auth at all -- Authelia is the only gate |
| Dozzle | 1 | No auth by default -- Authelia is the only gate |
| Prometheus | 1 | No auth built in; always gate, metrics expose internals |
| Alertmanager | 1 | No auth built in |
| Gatus | 1 | Status page; optional built-in OIDC but simpler to gate here |
| WatchYourLAN | 1 | Network ARP scanner, no built-in auth |
| NUT web UI | 1 | NUT daemon has no web UI; frontend web apps vary -- most have no auth |
| App keeps own auth (case 3) | ||
| Uptime Kuma | 3 | No proxy auth, no native TOTP -- Authelia is the only way to add 2FA |
| qBittorrent | 3 | Web UI has own auth; no proxy headers |
| Plex | 4 ⚠ | Do NOT use import authelia -- native clients go through Caddy but cannot complete browser-redirect auth; use Case 4, Plex handles its own auth |
| Emby | 4 ⚠ | Same as Plex |
| Gotify | 3 | Notification server; own auth |
| ntfy | 3 | Notification server; token-based auth |
| wg-easy | 3 | WireGuard web UI; WireGuard clients bypass Caddy (UDP 51820) |
| Umami | 3 | Analytics; own auth |
| phpIPAM | 3 | IP address management; own auth |
| Checkmk | 3 | Monitoring; own auth (LDAP in enterprise edition) |
| Snipe-IT | 3 | Asset management; own auth |
| Zammad | 3 | Help desk / ticketing; own auth |
| Lubelog | 3 | Vehicle maintenance; own auth |
| UniFi | 3 | Network controller; own auth |
| MeshCentral | 3 | Remote management; own auth; OIDC in enterprise builds |
| Vaultwarden | 3 or 4 | Strong native auth + TOTP; many skip Authelia here entirely |
| Router / NAS admin | 3 or 4 | Depends on firmware; case 4 is usually fine |
| App handles own auth (case 4) | ||
| Syncthing | 4 | Decent native auth; proxy auth not supported |
Getting git and authenticating to GitHub
Set your preferred editor first
The setup steps below use $EDITOR to open config files. It's a standard
shell variable that points to whichever editor you like. If it isn't set,
running $EDITOR somefile fails because the shell tries to execute an empty
string.
Check whether it's already set:
echo $EDITOR # blank = not set
Set it for the current session, or make it permanent by adding the export
to ~/.bashrc (bash) or ~/.zshrc (zsh):
# Pick one -- whatever you actually have installed:
export EDITOR=nano
export EDITOR=vim
export EDITOR=micro
export EDITOR="code --wait" # VS Code (the --wait keeps the terminal paused until you close the file)
# Make it permanent:
echo 'export EDITOR=nano' >> ~/.bashrc && source ~/.bashrc
# or for zsh:
echo 'export EDITOR=nano' >> ~/.zshrc && source ~/.zshrc
Once set, $EDITOR somefile opens that file in your chosen editor every time.
Install git
If git isn't installed on the server yet:
# Debian / Ubuntu / Raspberry Pi OS
sudo apt update && sudo apt install -y git
# Fedora / RHEL / Rocky / AlmaLinux
sudo dnf install -y git
The easiest way to authenticate is the GitHub CLI (gh). Install it by
adding GitHub's official apt repository (their Linux equivalent of a Homebrew
tap), then run gh auth login to authenticate interactively:
# Add the GitHub CLI apt repository
sudo apt install -y curl
curl -fsSL https://cli.github.com/packages/githubcli-archive-keyring.gpg \
| sudo dd of=/usr/share/keyrings/githubcli-archive-keyring.gpg
sudo chmod go+r /usr/share/keyrings/githubcli-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) \
signed-by=/usr/share/keyrings/githubcli-archive-keyring.gpg] \
https://cli.github.com/packages stable main" \
| sudo tee /etc/apt/sources.list.d/github-cli.list > /dev/null
sudo apt update && sudo apt install -y gh
# Authenticate -- follow the prompts (browser or paste a token)
gh auth login
When prompted: choose GitHub.com, HTTPS, and Login with a web
browser (or paste a personal access token if the server has no browser).
Once done, gh passes credentials to git automatically -- no extra config
needed.
On macOS the whole thing is two lines:
brew install git gh
gh auth login
First-run setup
# 0) Clone the auth stack onto the server.
# (The Frigate stack lives on the `main` branch and is cloned separately.)
gh repo clone outis1one/frigate_w_audio -- \
--branch authelia ~/docker/authelia
cd ~/docker/authelia
# 1) Create the external docker network (Caddy must also be on this).
docker network create caddy_net 2>/dev/null || true
# 2) Bootstrap the secrets directory.
mkdir -p authelia/secrets
openssl rand -hex 32 > authelia/secrets/JWT_SECRET
openssl rand -hex 32 > authelia/secrets/SESSION_SECRET
openssl rand -hex 32 > authelia/secrets/STORAGE_ENCRYPTION_KEY
chmod 600 authelia/secrets/*
# 3) Copy and edit .env -- this is the only place you set your domain.
# DOMAIN flows into authelia/configuration.yml and caddy/Caddyfile
# automatically via each tool's env-var substitution; no find-and-replace.
cp .env.example .env
$EDITOR .env # set DOMAIN, TZ; pin image versions if you want
# 4) For each site you added `import authelia` to in Caddy, uncomment the
# matching rule in authelia/configuration.yml under access_control.rules.
#
# WHY: default_policy is 'deny'. If a domain reaches Authelia with no
# matching rule, Authelia returns 403 -- even to a logged-in user. The
# rule tells Authelia the domain is valid and what auth level to require.
#
# Each rule is two lines -- just uncomment and pick one_factor or two_factor:
# - domain: 'cam.{{ env "DOMAIN" }}'
# policy: 'two_factor'
$EDITOR authelia/configuration.yml
# 5) Create your first user.
cp authelia/users_database.yml.example authelia/users_database.yml
$EDITOR authelia/users_database.yml # set username, email, displayname
# Generate the password hash:
docker compose run --rm authelia \
authelia crypto hash generate argon2 --password 'your-real-password'
# Paste the $argon2id$... output into the password: field.
# 6) Pre-create the Authelia log file.
# Docker creates a DIRECTORY at the bind-mount path if the file doesn't
# exist, which breaks fail2ban's mount. Create it as an empty file first.
touch authelia/authelia.log
# 7) Validate config before starting.
docker compose run --rm authelia \
authelia validate-config --config /config/configuration.yml
# Expect: "Configuration: validation complete" with no errors.
# 8) Wire up Caddy (see "Wire Caddy into Authelia" below).
# 9) Bring it up.
docker compose up -d
docker compose logs -f authelia # expect "Authelia is listening on ..."
docker compose logs -f fail2ban # expect "Jail authelia is now active"
Wire Caddy into Authelia
Open caddy/snippets.caddyfile. It contains copy-paste blocks for your
existing Caddyfile, not a replacement for it:
(authelia)and(accesslog)snippet definitions -- paste once near the top of your Caddyfile.auth.{env.DOMAIN}-- the Authelia portal block.- Per-service examples for all four cases (Frigate, Grafana, Gitea, Uptime Kuma, Homer, etc.) with the required per-app config notes inline.
Copy the blocks you need into your real Caddyfile and replace 192.168.x.x
with real upstream IPs. The domain is already templated as {env.DOMAIN} --
just make sure the DOMAIN environment variable is available to Caddy:
# Dockerized Caddy -- add to its .env or compose environment:
DOMAIN=yourdomain.com
# System Caddy -- add to /etc/caddy/caddy.env (or wherever systemd reads env):
echo 'DOMAIN=yourdomain.com' | sudo tee -a /etc/caddy/caddy.env
# Then make sure the systemd unit loads it:
# EnvironmentFile=/etc/caddy/caddy.env (in the [Service] section)
sudo systemctl daemon-reload && sudo systemctl reload caddy
Every site block should have import accesslog -- even case 4 sites.
fail2ban's caddy-4xx jail watches the one log file and covers all your
subdomains automatically. Scanners spray everything, not just gated sites.
Caddy access log path
fail2ban mounts /var/log/caddy from the host as read-only. Your Caddy
service must write to the same path. In your Caddy compose:
services:
caddy:
volumes:
- /var/log/caddy:/var/log/caddy
Create the directory before starting:
sudo mkdir -p /var/log/caddy
sudo chown caddy:caddy /var/log/caddy # adjust to your Caddy UID
Switching Frigate to Authelia (case 2)
Edit frigate_config/config.yml in your Frigate stack:
auth:
enabled: False
trusted_proxies:
- 172.18.0.0/16 # the caddy_net subnet -- find it with:
# docker network inspect caddy_net | jq '.[0].IPAM.Config'
proxy:
header_map:
user: remote-user # matches `copy_headers Remote-User` in (authelia) snippet
role: remote-groups
default_role: viewer
separator: '|'
# Optional shared secret -- prevents LAN header spoofing.
# Generate: openssl rand -hex 32
# Set the same value as `header_up X-Proxy-Secret` in caddy/Caddyfile.
# auth_secret: 'your-32-byte-hex'
Then uncomment cam.example.com in authelia/configuration.yml, restart
both services:
docker compose restart authelia
docker compose restart frigate # in your Frigate stack
Verify: https://cam.example.com in a private window goes to Authelia and
back without a Frigate login screen.
First login + TOTP enrollment
- Visit a protected subdomain in a private browser window.
- Caddy bounces you to
https://auth.example.com-- log in with your username and plaintext password. - If the rule is
two_factor, Authelia prompts you to register a second factor. Pick TOTP and scan the QR with your authenticator app. - Authelia writes a confirmation link to the filesystem notifier file:
Click the link to confirm TOTP registration.
docker compose exec authelia cat /config/notifications/notification.txt - Enter the TOTP code -- you're in. The
authelia_sessioncookie is scoped to your root domain and covers every protected subdomain automatically.
User management
Add a user
Append to authelia/users_database.yml, generate a hash:
docker compose run --rm authelia \
authelia crypto hash generate argon2 --password 'new-password'
Paste the hash as password:. Restart or wait 5 minutes for auto-reload.
Disable a user
Set disabled: true on their entry. Takes effect at next refresh.
Reset TOTP (force re-enrollment)
docker compose exec authelia \
authelia storage user totp delete --username USERNAME \
--config /config/configuration.yml
fail2ban
fail2ban does not need its own separate stack or compose file. It lives
alongside Authelia in this same docker-compose.yml. It uses host networking
(no docker network needed) and watches two log sources:
| Jail | Log | Trigger | Ban |
|---|---|---|---|
authelia |
./authelia/authelia.log |
3 failed logins in 10 min | 1 hour |
caddy-4xx |
/var/log/caddy/access.log |
30 HTTP 4xx in 2 min | 30 min |
The caddy-4xx jail covers every site on your Caddyfile as long as each
block has import accesslog. You don't need per-site jails.
Defense in depth: Authelia's regulation block locks the user account
after 3 bad passwords. fail2ban bans the source IP independently.
Verify jails are active
docker compose exec fail2ban fail2ban-client status
docker compose exec fail2ban fail2ban-client status authelia
docker compose exec fail2ban fail2ban-client status caddy-4xx
Test filters against real logs
docker compose exec fail2ban fail2ban-regex \
/var/log/authelia/authelia.log \
/data/filter.d/authelia.local
docker compose exec fail2ban fail2ban-regex \
/var/log/caddy/access.log \
/data/filter.d/caddy-4xx.local
If nothing matches: confirm log.format: 'text' in authelia/configuration.yml
and format json in the (accesslog) snippet in your Caddyfile.
Manually unban an IP
docker compose exec fail2ban fail2ban-client set authelia unbanip 1.2.3.4
docker compose exec fail2ban fail2ban-client set caddy-4xx unbanip 1.2.3.4
Tune thresholds
Edit fail2ban/data/jail.d/*.local, then:
docker compose restart fail2ban
Day-to-day
docker compose ps # services running?
docker compose logs -f authelia # follow Authelia
docker compose logs -f fail2ban # follow fail2ban
docker compose pull && docker compose up -d # upgrade images
Bump AUTHELIA_VERSION in .env when upgrading Authelia. After any
upgrade, re-run validate-config -- the schema evolves between releases.
Switching the notifier to SMTP
Replace notifier: in authelia/configuration.yml:
notifier:
disable_startup_check: false
smtp:
address: 'smtps://smtp.example.com:465'
username: 'authelia@example.com'
sender: 'Authelia <authelia@example.com>'
subject: '[Authelia] {title}'
# password loaded via AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE
Add the secret and wire it up:
echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD
chmod 600 authelia/secrets/SMTP_PASSWORD
Add to the authelia service environment in docker-compose.yml:
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/secrets/SMTP_PASSWORD
Restart and look for "Notifier SMTP startup check successful" in logs.
Security notes
.env,authelia/secrets/*,authelia/users_database.yml, andauthelia/db.sqlite3*are all gitignored. Rungit statusbefore every commit to confirm nothing sensitive is staged.- Authelia is not port-mapped to the host. Only containers on
caddy_netcan reach it; only Caddy is configured to forward_auth there. - TOTP secrets in the SQLite DB are encrypted at rest with
STORAGE_ENCRYPTION_KEY. Back up both the DB and the key file -- losing either means every user must re-enroll TOTP. regulationis per-user account lockout; fail2ban is per-IP. Both are on.
Troubleshooting
Redirect loop between a site and auth.example.com
Cookie domain mismatch. The domain: under session.cookies[] must be the
bare root domain (example.com), and every protected site must be a subdomain
of it served over HTTPS. Mixed HTTP/HTTPS won't work; the session cookie is
Secure.
"access denied" with no login prompt
default_policy: deny and no access_control rule for this domain. Add a
rule in authelia/configuration.yml and restart Authelia.
Authelia container restarts forever
docker compose logs authelia | head -50
Most often: missing/empty secret files in authelia/secrets/, bad YAML in
configuration.yml, or an invalid argon2 hash in users_database.yml.
Caddy can't resolve authelia
Caddy isn't on caddy_net. Add networks: [caddy_net] to your Caddy
service and caddy_net: external: true at the bottom of its compose, then:
docker compose up -d caddy
fail2ban bans don't block traffic
fail2ban is writing to the wrong iptables chain. With dockerized Caddy you
need chain = DOCKER-USER (already set in the shipped jail files). Verify:
sudo iptables -L DOCKER-USER -n
# Should show f2b-* jump rules.
fail2ban: authelia jail missing / "No such file" on authelia.log
Docker created a directory at ./authelia/authelia.log instead of a file
because the file didn't exist when the container started:
docker compose down fail2ban
rm -rf authelia/authelia.log # remove the directory Docker created
touch authelia/authelia.log # create as an empty file
docker compose up -d fail2ban
Frigate still shows its own login after switching to proxy auth
- Confirm
auth.enabled: Falseinfrigate_config/config.yml. - Confirm
trusted_proxiessubnet matches yourcaddy_netsubnet:docker network inspect caddy_net | grep -A2 '"Config"' - Restart Frigate:
docker compose restart frigate(in your Frigate stack).