Previously every example.com had to be found and replaced manually.
Now a single DOMAIN=yourdomain.com in .env propagates everywhere:
- .env.example: add DOMAIN=example.com with explanation
- docker-compose.yml: pass DOMAIN into authelia container environment
- authelia/configuration.yml: use {{ env "DOMAIN" }} in totp.issuer,
access_control.rules, and all four session.cookies[] fields
(Authelia 4.38+ Go template substitution)
- caddy/Caddyfile: use {env.DOMAIN} in all site block addresses
(Caddy native env substitution); update header comment explaining
how to set DOMAIN for system vs dockerized Caddy
- README.md: update step 3 to explain DOMAIN is the only change needed;
update step 4 to say just uncomment the right rule; update Caddy
wiring section with DOMAIN env var instructions for both Caddy modes
https://claude.ai/code/session_012eTokAaGiZo7aGt1T2W9BC
535 lines
19 KiB
Markdown
535 lines
19 KiB
Markdown
# 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/
|
|
└── Caddyfile # copy/merge into your Caddy setup
|
|
```
|
|
|
|
## 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.<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. |
|
|
| **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.
|
|
|
|
### 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 authentication", "SSO via reverse proxy"
|
|
- Support for `X-Forwarded-User`, `X-Remote-User`, or `Remote-User`
|
|
|
|
| App | Proxy auth? | Notes |
|
|
|-----|-------------|-------|
|
|
| Frigate 0.14+ | Yes | `auth.enabled: False` + `proxy:` block in config.yml |
|
|
| Grafana | Yes | `[auth.proxy]` section in grafana.ini |
|
|
| Gitea / Forgejo | Yes | `REVERSE_PROXY_AUTHENTICATION_USER` in app.ini |
|
|
| Nextcloud | Yes | `TRUSTED_PROXIES` env + `overwriteprotocol = https` |
|
|
| Home Assistant | Yes | `trusted_networks` auth provider + `use_x_forwarded_for` |
|
|
| Jellyfin | Partial | Community plugin required |
|
|
| Portainer | No | Use Authelia OIDC integration instead |
|
|
| Vaultwarden | No | Use Authelia OIDC integration instead |
|
|
| Router/NAS admin | Rarely | Use case 3 (2FA gate) or case 4 |
|
|
|
|
## 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/Caddyfile`. It defines:
|
|
|
|
- `(authelia)` -- reusable snippet: add `import authelia` to any site block.
|
|
- `(accesslog)` -- writes Caddy's JSON access log to `/var/log/caddy/access.log`
|
|
so fail2ban's `caddy-4xx` jail can watch it.
|
|
- `auth.{env.DOMAIN}` -- the Authelia portal.
|
|
- Example site blocks for all four cases.
|
|
|
|
Copy the relevant blocks 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 <authelia@example.com>'
|
|
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).
|