# 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-USER` iptables chain: drops happen at the host edge before traffic reaches any docker-published port. - Caddy is not in this stack. Copy `caddy/Caddyfile` into 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, replace `caddy_net` everywhere in this repo. - Caddy v2.5.1 or newer (for the `forward_auth` directive; tested on v2.11.2). - A root domain you control. DNS A records for `auth.` 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. | | **2** | Yes | Yes | `import authelia` in Caddy + rule in Authelia + disable app's own login form. Single login. | | **3** | Yes | No | `import authelia` in Caddy + rule in Authelia. 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. Use `two_factor` -- this URL controls a speaker. - **`cam.example.com`** (Frigate UI) -- **case 2**. Frigate 0.14+ supports proxy auth. Disable Frigate's login form and let Authelia drive both the access gate and the role mapping (admin vs. viewer) via headers. - **Router admin / NAS UI** -- **case 3** if you want a 2FA gate in front, **case 4** if you just leave it to the app. 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) -- 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 | |-----|------|-------| | Frigate 0.14+ | 2 | `auth.enabled: False` + `proxy:` block in config.yml | | Grafana | 2 | `[auth.proxy]` in grafana.ini; `GF_AUTH_PROXY_ENABLED=true` | | Gitea / Forgejo | 2 | `ENABLE_REVERSE_PROXY_AUTHENTICATION=true` in app.ini | | Nextcloud | 2 | `trusted_proxies` + `user_external` app + HTTP header auth | | Paperless-ngx | 2 | `PAPERLESS_ENABLE_HTTP_REMOTE_USER=true` | | Miniflux | 2 | `AUTH_PROXY_HEADER=Remote-User` env var | | Home Assistant | 2 | `trusted_networks` auth provider | | BookStack | 2 (partial) | HTTP header auth via SAML2 or custom auth method | | Immich | 2 (OIDC) | No header auth; configure Authelia as OIDC provider | | Mealie | 2 (OIDC) | OIDC support; configure Authelia as OIDC provider | | Homer / Heimdall | 1 | No auth at all -- Authelia is the only gate | | Dozzle | 1 | No auth by default -- Authelia is the only gate | | Uptime Kuma | 3 | No proxy auth, no native TOTP -- case 3 is the only way to add 2FA | | Portainer | 3 | No proxy auth headers; has OIDC for full SSO | | Jellyfin | 3 | Community plugin for header auth; otherwise case 3 | | Syncthing | 4 | Decent native auth; proxy auth not supported | | Vaultwarden | 3 or 4 | Strong native auth + TOTP; many skip Authelia here entirely | | Router / NAS admin | 3 or 4 | Depends on firmware; usually case 4 is fine | ## 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: ```bash 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): ```bash # 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: ```bash # 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: ```bash # 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: ```bash brew install git gh gh auth login ``` ## First-run setup ```bash # 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) Uncomment access_control.rules in authelia/configuration.yml for the # sites you want to gate. The domain is already templated -- just # uncomment the right case block and choose one_factor or 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: ```bash # 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: ```yaml services: caddy: volumes: - /var/log/caddy:/var/log/caddy ``` Create the directory before starting: ```bash 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: ```yaml 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: ```bash 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 1. Visit a protected subdomain in a private browser window. 2. Caddy bounces you to `https://auth.example.com` -- log in with your username and plaintext password. 3. If the rule is `two_factor`, Authelia prompts you to register a second factor. Pick **TOTP** and scan the QR with your authenticator app. 4. Authelia writes a confirmation link to the filesystem notifier file: ```bash docker compose exec authelia cat /config/notifications/notification.txt ``` Click the link to confirm TOTP registration. 5. Enter the TOTP code -- you're in. The `authelia_session` cookie 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: ```bash 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) ```bash 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 ```bash 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 ```bash 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 ```bash 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: ```bash docker compose restart fail2ban ``` ## Day-to-day ```bash 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`: ```yaml notifier: disable_startup_check: false smtp: address: 'smtps://smtp.example.com:465' username: 'authelia@example.com' sender: 'Authelia ' subject: '[Authelia] {title}' # password loaded via AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE ``` Add the secret and wire it up: ```bash echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD chmod 600 authelia/secrets/SMTP_PASSWORD ``` Add to the authelia service environment in `docker-compose.yml`: ```yaml - 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`, and `authelia/db.sqlite3*` are all gitignored. Run `git status` before every commit to confirm nothing sensitive is staged. - Authelia is not port-mapped to the host. Only containers on `caddy_net` can 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. - `regulation` is 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 ```bash 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: ```bash 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: ```bash 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: ```bash 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 1. Confirm `auth.enabled: False` in `frigate_config/config.yml`. 2. Confirm `trusted_proxies` subnet matches your `caddy_net` subnet: ```bash docker network inspect caddy_net | grep -A2 '"Config"' ``` 3. Restart Frigate: `docker compose restart frigate` (in your Frigate stack).