Compare commits

..
5 Commits
Author SHA1 Message Date
Outis 2de66045dc Merge pull request #1 from outis1one/clDemoe/camera-live-view-app-dlYME
ClDemoe/camera live view app dl yme
2026-04-26 10:09:26 -04:00
Claude 09d5b28e3d Revert camera dropdown; add PEER_LINKS for sibling Pis
The dropdown allowed switching the video feed but PTT always pointed at
this Pi's local speaker, so picking a non-co-located camera could have
let a user talk into the wrong room. Each Pi is now hardcoded to the
camera at its own location via CAMERA_NAME, with optional PEER_LINKS to
render quick-jump buttons to sibling doorbell Pis at other URLs.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 21:23:17 +00:00
Claude b3940685c4 Add camera selector dropdown to doorbell page
Replaces the hardcoded CAMERA_NAME with a CAMERAS list. The page renders
a dropdown when more than one camera is configured and remembers the
choice in localStorage; switching tears down and reopens the WebRTC
connection.

https://claude.ai/code/session_013XZ1vmgk78k2PEQ5DmJhF3
2026-04-25 19:10:37 +00:00
Outis 8609175410 Update FN_FRIGATE__PUBLIC_URL to new domain 2026-04-25 14:47:31 -04:00
Outis 5d7df0c0c8 Initial home camera stack 2026-04-25 11:06:10 -04:00
23 changed files with 1383 additions and 2104 deletions
+32 -18
View File
@@ -1,22 +1,36 @@
# Copy to .env and fill in. DO NOT commit .env.
# Copy to .env and fill in real values. DO NOT commit .env to git.
#
# Secrets themselves live as files under authelia/secrets/ (also gitignored)
# so they can be mounted into the container without env-var leakage. This
# .env only holds non-secret tunables.
# Env var prefixes:
# FRIGATE_ - referenced via {FRIGATE_*} substitution in frigate_config/config.yml
# FN_ - read directly by frigate-notify (Viper). Note the DOUBLE underscore
# between YAML hierarchy levels: frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD
# ---------------------------------------------------------------------------
# Your root domain. This single value flows into authelia/configuration.yml
# (via Authelia's {{ env "DOMAIN" }} template substitution) and into
# caddy/Caddyfile (via Caddy's {env.DOMAIN} substitution).
# No manual find-and-replace needed -- just set this.
# ---------------------------------------------------------------------------
DOMAIN=example.com
# ---- Camera credentials ----
FRIGATE_RTSP_USER=admin
FRIGATE_RTSP_PASSWORD=pick_a_url_safe_password
FRIGATE_FRONT_DOOR_IP=192.168.1.100
# Pin your image versions. Bump to current stable when you upgrade --
# check https://github.com/authelia/authelia/releases and
# https://github.com/crazy-max/docker-fail2ban/releases.
AUTHELIA_VERSION=4.39.19
FAIL2BAN_VERSION=1.1.0-r0
FRIGATE_RTSP_USER1=admin
FRIGATE_RTSP_PASSWORD1=changeme
FRIGATE_BACK_DOOR_IP=192.168.1.101
# Used by both containers for log timestamps. Set to your IANA zone.
TZ=America/New_York
FRIGATE_RTSP_USER2=admin
FRIGATE_RTSP_PASSWORD2=changeme
FRIGATE_SQUIRREL_IP=192.168.1.102
# Future Anpviz camera with mic
# FRIGATE_RTSP_USER3=admin
# FRIGATE_RTSP_PASSWORD3=changeme
# FRIGATE_ANPVIZ_IP=192.168.1.103
# ---- MQTT broker ----
# Used by Frigate via {FRIGATE_MQTT_*} substitution
FRIGATE_MQTT_USER=frigate
FRIGATE_MQTT_PASSWORD=pick_a_strong_mqtt_password
# ---- frigate-notify ----
# Same MQTT password as above. Note double underscores between YAML levels.
FN_FRIGATE__MQTT__PASSWORD=same_value_as_FRIGATE_MQTT_PASSWORD
FN_FRIGATE__SERVER=http://frigate:5000
FN_FRIGATE__PUBLIC_URL=https://frigate.yourdomain.com
FN_ALERTS__NTFY__SERVER=https://ntfy.yourdomain.com
+23 -17
View File
@@ -1,23 +1,26 @@
# Secrets — never commit
# Secrets & credentials
.env
authelia/secrets/JWT_SECRET
authelia/secrets/SESSION_SECRET
authelia/secrets/STORAGE_ENCRYPTION_KEY
authelia/secrets/SMTP_PASSWORD
# Authelia runtime state
authelia/users_database.yml
authelia/db.sqlite3
authelia/db.sqlite3-*
authelia/authelia.log
authelia/notifications/notification.txt
# Mosquitto runtime state
mosquitto/config/passwd
mosquitto/data/*
mosquitto/log/*
!mosquitto/data/.gitkeep
!mosquitto/log/.gitkeep
# fail2ban runtime state
fail2ban/data/db/
fail2ban/data/fail2ban.sqlite3
fail2ban/data/*.bak
fail2ban/data/jail.d/*.bak
fail2ban/data/filter.d/*.bak
# Frigate runtime state
frigate_config/frigate.db*
frigate_config/model_cache/
frigate_config/backup.db
frigate_config/backup_config.yaml
frigate_config/*.bak
frigate_config/config.yaml-b4-*
frigate_config/*.16
frigate_config/*.old
# Frigate media (way too big for git; lives on a separate volume)
media/
frigate_media/
# Editor / OS junk
*.swp
@@ -25,3 +28,6 @@ fail2ban/data/filter.d/*.bak
.DS_Store
.vscode/
.idea/
# Archives
*.tar.gz
-456
View File
@@ -1,456 +0,0 @@
# HANDOFF -- script to add a site behind Authelia
This doc is for a fresh session where you'll write a script that
automates the "add a new site behind Authelia" workflow currently done
by hand. Everything below is the context you need to write it
correctly.
## Goal
One command, e.g.
```
./add-site --subdomain doorbell --upstream 192.168.1.60:5555 --case 1 --policy two_factor
```
does all of:
1. Adds the matching rule to `authelia/configuration.yml` under
`access_control.rules:`.
2. Adds (or modifies) the matching site block in the user's Caddyfile.
If a site block already exists with `basic_auth { ... }`, removes
that and inserts `import authelia` instead.
3. Validates Authelia config (`authelia validate-config`).
4. Validates Caddy config (`caddy validate`).
5. Restarts Authelia, then reloads Caddy. Order matters.
6. Optionally `curl`-tests the new URL and reports.
Idempotent: re-running with the same args is a no-op.
## Stack context (what's already running)
- **Authelia 4.39.19**, file backend, SQLite local storage, in-memory
sessions, filesystem notifier. Compose project lives at
`~/docker/authelia/`. Container name `authelia`.
- **fail2ban** as a sidecar in the same compose project. Watches
`./authelia/authelia.log` and `/var/log/caddy/access.log`.
- **Caddy** is in its own compose project at `~/docker/caddy/`
(assumption -- script should accept the path as a parameter).
Container name `caddy`. On the external docker network `caddy_net`.
- Both Caddy and Authelia are on `caddy_net`. Caddy reaches Authelia
as `authelia:9091`.
- The portal is `auth.{DOMAIN}` with `policy: bypass`.
- `default_policy: deny` -- every gated domain MUST have a rule.
- DOMAIN substitution:
- Authelia uses Go templates: `'{{ env "DOMAIN" }}'`. Requires
`X_AUTHELIA_CONFIG_FILTERS=template` env var (already set in the
docker-compose.yml).
- Caddy uses `{env.DOMAIN}`. The Caddy compose passes `DOMAIN`
through to the Caddy container.
## Files the script touches
| File | What lives there | Who edits |
|------|------------------|-----------|
| `~/docker/authelia/authelia/configuration.yml` | `access_control.rules:` list | the script |
| `<user's Caddyfile>` (path: parameter) | site blocks | the script |
| `~/docker/authelia/.env` | `DOMAIN=...`, `TZ=...`, version pins | read-only (script reads DOMAIN from here OR the environment) |
The script does NOT touch:
- `authelia/users_database.yml` (user management is separate)
- `authelia/secrets/*` (manual one-time bootstrap)
- `frigate_config/config.yml` or any other app's own config
(case 2a/2b require app-side edits the script can't safely automate
-- it should print instructions instead)
- DNS, TLS, anything outside the local Caddy + Authelia configs
## Manual workflow the script automates
For reference, here's what a human does today to add `foo.example.com`
as a case-1 site:
```bash
# 1. Edit authelia/configuration.yml -- add under access_control.rules:
# - domain: 'foo.{{ env "DOMAIN" }}'
# policy: 'two_factor'
# 2. Edit your real Caddyfile -- add a new block (or modify existing):
# foo.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.1.60:5555
# }
# 3. Validate before reloading
docker compose -f ~/docker/authelia/docker-compose.yml run --rm authelia \
authelia validate-config --config /config/configuration.yml
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
caddy validate --config /etc/caddy/Caddyfile
# 4. Restart Authelia FIRST (so the rule is live before Caddy starts
# forwarding to it -- otherwise default_policy: deny returns 403)
docker compose -f ~/docker/authelia/docker-compose.yml restart authelia
# 5. Reload Caddy (zero-downtime)
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
caddy reload --config /etc/caddy/Caddyfile
# 6. Test
curl -sI -o /dev/null -w "%{http_code}\n" https://foo.example.com
# expect 302 redirect to auth.example.com
```
## Cases the script must handle
The full case taxonomy is in `authelia/configuration.yml` and
`caddy/snippets.caddyfile`. Summary:
### Case 1 -- no app auth, Authelia is the only gate
**Authelia rule:**
```yaml
- domain: 'SUBDOMAIN.{{ env "DOMAIN" }}'
policy: 'two_factor' # or one_factor
```
**Caddy block:**
```caddyfile
SUBDOMAIN.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy UPSTREAM_IP:UPSTREAM_PORT
}
```
Most common case. The migration path from `basic_auth` -- the script
should handle "block exists with basic_auth, swap it for import authelia".
### Case 2a -- app supports trusted-header proxy auth
Same Caddy block as case 1. Same Authelia rule. **Plus** an app-side
config change the script CANNOT safely automate (each app is different:
Frigate's `auth.enabled: False`, Grafana's `[auth.proxy]` section,
Gitea's `ENABLE_REVERSE_PROXY_AUTHENTICATION`, etc.). Script should
print app-specific instructions from a lookup table and require
`--ack-app-config-done` to proceed.
### Case 2b -- app supports OIDC
Same Caddy block as case 1. Same Authelia rule. **Plus** an
`identity_providers.oidc.clients[]` entry to add to
`configuration.yml`. Each app needs its own `client_id`,
`client_secret`, `redirect_uris`, etc.
This is more involved. **Suggested**: out of scope for v1 of the script;
print a pointer to Authelia's OIDC docs and skip.
### Case 3 -- app keeps its own auth, Authelia adds 2FA in front
Caddy block and Authelia rule are identical to case 1. The user just
keeps logging into the app after Authelia. The script doesn't need to
distinguish case 1 from case 3 mechanically -- the only difference is
the user's mental model.
### Case 4 -- no Authelia involvement
```caddyfile
SUBDOMAIN.{env.DOMAIN} {
import accesslog # NO import authelia
reverse_proxy UPSTREAM_IP:UPSTREAM_PORT
}
```
**No Authelia rule.** The script's job is just the Caddy edit + reload.
Useful for things like Plex/Emby where Authelia's redirect breaks
native clients.
## Validation hooks the script must run
In order, before any reload:
1. **Authelia config:**
```bash
docker compose -f ~/docker/authelia/docker-compose.yml run --rm authelia \
authelia validate-config --config /config/configuration.yml
```
Exit 0 = good. Anything else = abort, restore the file from backup.
2. **Caddy config:**
```bash
docker compose -f ~/docker/caddy/docker-compose.yml exec caddy \
caddy validate --config /etc/caddy/Caddyfile
```
Exit 0 = good. Anything else = abort, restore Caddyfile from backup.
After reload:
3. **HTTP probe:**
```bash
curl -sI -o /dev/null -w "%{http_code}\n" https://SUBDOMAIN.DOMAIN
```
- Case 1 / 2a / 2b / 3: expect `302` (redirect to Authelia).
- Case 4: expect `200` or whatever the upstream returns.
- `403` means the Caddy block has `import authelia` but the Authelia
rule isn't in place (or wasn't picked up). Most common script bug.
## Reload semantics
**Order**: Authelia restart, THEN Caddy reload. Reverse order is briefly
broken: Caddy starts forwarding to Authelia for a domain Authelia
doesn't yet have a rule for, and `default_policy: deny` returns 403 to
the user.
**Authelia restart**: full container restart. ~3-5 second outage on
auth.{DOMAIN}. Acceptable for household use; if you want zero-downtime
later, Authelia supports config reload via SIGHUP -- not used here.
**Caddy reload**: `caddy reload` is genuinely zero-downtime. It loads
the new config, validates, and atomically swaps. If validation fails
the old config keeps running.
**Rollback**: if anything fails, the script should revert the file
edits from a `.bak` it took before mutating. Easiest pattern:
```bash
cp authelia/configuration.yml authelia/configuration.yml.bak
cp $CADDYFILE_PATH $CADDYFILE_PATH.bak
# ... edits ...
# if any validate fails:
mv authelia/configuration.yml.bak authelia/configuration.yml
mv $CADDYFILE_PATH.bak $CADDYFILE_PATH
```
## Idempotency
The script must detect and short-circuit when the desired end state
already exists:
- **Authelia rule already present**: parse `access_control.rules:`, look
for an entry with matching `domain:`. If found and policy matches, skip
the YAML edit. If found and policy differs, prompt or fail (don't
silently overwrite).
- **Caddy block already present with `import authelia`**: parse the
Caddyfile, look for `SUBDOMAIN.{env.DOMAIN} {` block. If found and
it has `import authelia`, skip the Caddy edit.
- **Caddy block exists with `basic_auth`**: this is the migration case.
Remove the `basic_auth { ... }` lines, add `import authelia` if
missing. Preserve everything else (transport, header_up, etc).
- **Caddy block exists without auth at all**: just add `import authelia`.
After all checks: if neither file actually changed, skip both reloads
(important -- restarting Authelia for a no-op kicks every active
session).
## Failure modes the script must handle
- `DOMAIN` env var not set / `.env` not present -> abort early with
clear error.
- `caddy_net` docker network doesn't exist -> abort.
- Authelia container not running -> can still edit config and validate;
reload step needs to be skipped or attempted with helpful error.
- Caddy container not running -> same.
- Caddyfile path doesn't exist or isn't writable -> abort.
- The Caddyfile doesn't have `(authelia)` and `(accesslog)` snippets
defined -> the script could either inject them at the top of the file
(fragile) or refuse and tell the user to run a one-time setup step
first. **Recommended**: refuse, with a clear "run `./bootstrap-caddy`
first" message.
- Subdomain conflicts with an existing block that's NOT just a
`basic_auth` migration target (e.g. an entirely different upstream)
-> prompt, don't auto-overwrite.
- A site block exists with `basic_auth` AND something else complicated
(custom matchers, multiple `handle` blocks) -> migration is hard.
Recommended: detect the simple case (single `basic_auth { ... }`
inside the block) and refuse the complex case.
- `validate-config` or `caddy validate` fails -> rollback both files,
report the validator's stderr, exit non-zero.
- HTTP probe fails post-reload -> log the symptom but don't auto-revert;
user may have DNS not pointing yet, etc.
## YAML editing -- preserve comments
`authelia/configuration.yml` has substantial comments (the
"ALSO PASTE INTO CADDYFILE" blocks, case explanations). A naive YAML
round-trip will eat them. Use a comment-preserving library:
- **Python**: `ruamel.yaml` with `YAML(typ='rt')` (round-trip mode).
- **Go**: `gopkg.in/yaml.v3` is comment-aware.
- **`yq`** (the Go-based one from mikefarah): preserves comments
reasonably well for simple ops. Adding a list item:
```bash
yq -i '.access_control.rules += [{"domain": "foo.{{ env \"DOMAIN\" }}", "policy": "two_factor"}]' \
authelia/configuration.yml
```
Note the escaping pain with `{{ env "DOMAIN" }}`. Test before
committing.
The script should anchor inserts at a stable location. The least-bad
anchor is the END of `access_control.rules:` -- always append, never
splice in the middle.
## Caddyfile editing -- there is no good parser
Caddyfile has its own grammar; standard YAML/JSON tools won't touch it.
Options, in order of pragmatism:
1. **Text-based pattern matching** (recommended for v1). Anchors:
- Find `^SUBDOMAIN\.\{env\.DOMAIN\} \{$` to detect existing block.
- For the basic_auth migration: use a small state machine to find
`basic_auth {` ... `}` inside the matched block and delete those
lines, then ensure `import authelia` and `import accesslog` lines
exist.
- For new-block insertion: append at end of file, separated by a
blank line.
2. **`caddy adapt`**: converts Caddyfile to JSON. You could edit the
JSON, then... there's no Caddyfile emitter. Adapt is one-way. Skip.
3. **`caddy fmt`**: normalizes whitespace in a Caddyfile, doesn't
semantically edit. Useful AFTER your edits to clean up.
The text-based approach is fragile for arbitrary Caddyfiles but
predictable for the conventions this stack uses (one site block per
subdomain, snippets imported at top, no exotic matchers in gated
sites).
## Suggested architecture
```
add-site
├── lib/
│ ├── env.sh # find DOMAIN, container names, Caddyfile path
│ ├── yaml_edit.sh # ruamel.yaml or yq wrapper for access_control
│ ├── caddyfile_edit.sh # awk/sed-based site-block patcher
│ ├── validate.sh # authelia + caddy validators
│ └── reload.sh # restart authelia + reload caddy in order
├── cases/
│ ├── case-1.sh # no-app-auth recipe
│ ├── case-2a.md # printable app-side instructions table
│ ├── case-2b.md # OIDC out-of-scope notice + pointer
│ └── case-4.sh # no-Authelia recipe
├── add-site # main entrypoint
└── README.md
```
Or in Python with `ruamel.yaml` and a small Caddyfile patcher class.
Either is fine; the bash version has fewer install steps for an
end-user.
## Concrete test cases the script must pass
Use these as fixtures.
### Test 1: fresh case-1 add
Pre-state:
- `authelia/configuration.yml` has only the `auth.{DOMAIN}` bypass rule.
- Caddyfile has `(authelia)` and `(accesslog)` snippets defined, no
`foo.{env.DOMAIN}` block.
Invocation:
```
./add-site --subdomain foo --upstream 192.168.1.60:5555 --case 1
```
Post-state:
- `access_control.rules:` has new entry for `foo.{{ env "DOMAIN" }}`.
- Caddyfile has new `foo.{env.DOMAIN} { ... }` block with `import
authelia` and `import accesslog`.
- `validate-config` and `caddy validate` both pass.
- `curl -sI https://foo.example.com` returns 302.
### Test 2: idempotent re-run
Run Test 1's invocation twice. Second run: no file edits, no reloads,
exit 0 with "already configured" message.
### Test 3: basic_auth migration
Pre-state:
- Caddyfile has `foo.{env.DOMAIN} { basic_auth { user $2a$... }
reverse_proxy ... }`.
Invocation:
```
./add-site --subdomain foo --case 1 --migrate-basic-auth
```
Post-state:
- The `basic_auth { ... }` lines are gone.
- `import authelia` and `import accesslog` are present.
- `reverse_proxy` line is unchanged.
- Authelia rule added.
### Test 4: validation failure rollback
Pre-state: introduce a typo by hand into the YAML insert template
(e.g. `polciy:` instead of `policy:`). Simulate by mocking the
template.
Expected: `validate-config` fails, both files restored from `.bak`,
non-zero exit, no reload attempted.
### Test 5: case-4 (no Authelia)
Invocation:
```
./add-site --subdomain plex --upstream 192.168.1.5:32400 --case 4
```
Post-state:
- Caddyfile has new `plex.{env.DOMAIN}` block with `import accesslog`,
NO `import authelia`.
- `access_control.rules:` is UNCHANGED.
- Caddy reloads, Authelia is NOT restarted.
## Out of scope (tell the user, don't try to automate)
- DNS A-record creation. Caddy will fail to issue a cert for a domain
that doesn't resolve. Print a "make sure DNS is pointing at this
host" reminder when the script starts.
- TLS / Let's Encrypt failures. Caddy auto-provisions; if it fails,
it's usually DNS or rate-limit. The script should not try to debug.
- App-side proxy-auth config (case 2a). Each app is different. Print
the lookup-table snippet from `caddy/snippets.caddyfile` for that
app and require `--ack-app-config-done` before running.
- OIDC client setup (case 2b). Big enough that it deserves its own
tool. Out of scope.
- User management (`users_database.yml`).
- Secret rotation (`authelia/secrets/*`).
- TOTP enrollment.
## Quick reference: existing files
If you want to read what's there to understand the conventions:
- `docker-compose.yml` -- the auth stack compose, including the
`X_AUTHELIA_CONFIG_FILTERS=template` and `DOMAIN=${DOMAIN}` env vars.
- `authelia/configuration.yml` -- reference for the rule format,
comment style, and the "ALSO PASTE INTO CADDYFILE" blocks under
each case in `access_control.rules:`.
- `caddy/snippets.caddyfile` -- canonical examples of Caddy site
blocks for every case, including app-specific notes the case-2a
table can be extracted from.
- `.env.example` -- the shape of `.env` (DOMAIN, TZ, version pins).
- `README.md` -- the comprehensive bootstrap walkthrough; the
"Adding a new protected site" section is what the script
automates.
## When in doubt
- For YAML edits, dry-run with
`docker compose run --rm authelia authelia config template --config
/config/configuration.yml` -- prints the rendered config so you can
see exactly what Authelia will see.
- For Caddyfile edits, `caddy fmt --overwrite Caddyfile` normalizes
whitespace and `caddy validate` catches syntax errors. Run BOTH
before any reload.
- The single most useful debug command for "why is my site getting
403":
```bash
docker compose -f ~/docker/authelia/docker-compose.yml exec authelia \
tail -f /config/authelia.log
```
Then hit the URL. The log line tells you exactly which rule (or
default_policy) made the call.
+215 -712
View File
@@ -1,770 +1,273 @@
# Authelia + fail2ban
# Home camera stack
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`.
Frigate NVR + Mosquitto MQTT + frigate-notify -> ntfy push notifications,
with face recognition and license plate recognition on the Frigate side.
Includes a planned Pi Zero W "doorbell speaker" stack (push-to-talk web
page fronted by Caddy) that's wired but not yet deployed.
## Architecture
```
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
+---------------+
Cameras (RTSP) Phone / browser
| |
v v
+------------+ MQTT events +----------------+ +-------------+
| Frigate | <-------------> | Mosquitto | | Pi |
| (NVR) | | broker | | (planned) |
+-----+------+ +-------+--------+ | speaker |
| WebRTC / MSE | +------+------+
v v ^
+------------+ +------------------+ |
| Caddy |<--HTTPS--------| frigate-notify | |
| proxy | | (event consumer) | |
+------------+ +--------+---------+ |
| | |
| cam.yourdomain.com ntfy push |
| doorbell.yourdomain.com |
+-------HTTPS---------------> PTT button ------------+
```
- 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 blocks from `caddy/snippets.caddyfile` into your Caddy setup.
## What's deployed vs planned
| Component | Status |
|---|---|
| Frigate 0.17 | Deployed |
| Mosquitto MQTT broker | Deployed |
| frigate-notify -> ntfy | Deployed |
| Face recognition | Configured, needs training |
| License plate recognition | Configured |
| Caddy reverse proxy | Whatever your existing Caddy does |
| Pi Zero W doorbell speaker | Planned, not deployed yet |
## 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
home-cameras/
|-- docker-compose.yml # frigate + mosquitto + frigate-notify
|-- .env.example # template -- copy to .env, fill in
|-- .gitignore
|-- README.md # this file
|
|-- frigate_config/
| |-- config.yml # production: main stream for detect
| `-- alternatives/
| `-- config-simple.yml # lower-CPU fallback: sub-stream detect
|
|-- frigate-notify/
| `-- config.yml # MQTT in, ntfy out, face-aware templates
|
|-- mosquitto/
| |-- config/
| | `-- mosquitto.conf # broker config (allow_anonymous false)
| |-- data/.gitkeep
| `-- log/.gitkeep
|
|-- caddy/
| `-- Caddyfile # reverse proxy for both subdomains
|
`-- pi/ # runs on the Pi, NOT on the Frigate host
|-- README.md # Pi-specific setup
|-- server.py # Flask + WebSocket PTT receiver
|-- doorbell.service # systemd unit
`-- install.sh # one-shot installer
```
## Prerequisites
## First-run on the Frigate host
- 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.
Prerequisites:
- Docker + docker compose v2
- DNS records for any subdomains you intend to use, pointing at your Caddy
host
- An existing Caddy instance (separate from this stack) handling TLS at the
edge, OR adapt for whatever reverse proxy you use
- Coral USB stick plugged in (or adjust `detectors:` for a different accel)
- A media disk mounted on the host; update the `/media/frigate` path in
`docker-compose.yml`
## 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. Use `two_factor` -- this URL controls a speaker.
- **`cam.example.com`** (Frigate UI) -- **case 2a**. Frigate 0.14+ supports
proxy auth via `Remote-User` header. 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-User` to 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:
Steps:
```bash
echo $EDITOR # blank = not set
```
git clone https://github.com/YOU/home-cameras.git
cd home-cameras
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
### 0. Clone the repo
The Frigate stack lives on `main`. This auth stack is on the `authelia` branch -- clone it separately into its own directory.
```bash
git clone -b authelia \
https://github.com/outis1one/frigate_w_audio.git \
~/docker/authelia
```
```bash
cd ~/docker/authelia
```
### 1. Create the external Docker network
Caddy must join this same network so it can reach Authelia by container name. Skip if `caddy_net` already exists.
```bash
docker network create caddy_net 2>/dev/null || true
```
### 2. Generate secrets
Authelia loads these from files so they never appear in `docker inspect` or process listings.
```bash
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. Set your domain
`DOMAIN` is the only value you set here. It flows into `authelia/configuration.yml` via Go template substitution and into your Caddyfile via `{env.DOMAIN}` -- no find-and-replace needed anywhere else.
```bash
# 1) Configure secrets
cp .env.example .env
```
```bash
$EDITOR .env
```
Set `DOMAIN=yourdomain.com` and `TZ=Your/Timezone`. Save and close.
# 2) Make mosquitto dirs writable by the container's mosquitto user (UID 1883)
sudo chown -R 1883:1883 mosquitto/
### 4. Add access control rules
# 3) Bootstrap mosquitto BEFORE applying the production config.
# The committed mosquitto.conf has allow_anonymous false + password_file,
# which means we need to create the password file first OR temporarily
# flip to allow_anonymous true to start.
#
# Easiest: temporarily edit mosquitto/config/mosquitto.conf:
# allow_anonymous false -> allow_anonymous true
# comment out: password_file /mosquitto/config/passwd
# Then start:
docker compose up -d mosquitto
docker compose logs mosquitto --tail 10 # expect "running"
**This step and step 8 (Caddy wiring) must be done together for every site you want to gate. Both are required -- neither alone is enough.**
# 4) Create the MQTT user (use the password from your .env)
docker compose exec mosquitto mosquitto_passwd -c -b \
/mosquitto/config/passwd frigate \
"$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)"
```bash
$EDITOR authelia/configuration.yml
```
sudo chown 1883:1883 mosquitto/config/passwd
sudo chmod 0640 mosquitto/config/passwd
Scroll to `access_control.rules`. Uncomment the rule for each site you want to protect and choose a policy:
# 5) Restore mosquitto.conf to its committed state:
# allow_anonymous true -> allow_anonymous false
# uncomment: password_file /mosquitto/config/passwd
git checkout mosquitto/config/mosquitto.conf
docker compose restart mosquitto
```yaml
- domain: 'cam.{{ env "DOMAIN" }}'
policy: 'two_factor'
```
# 6) Verify auth works
sudo apt install -y mosquitto-clients
mosquitto_sub -h 127.0.0.1 -u frigate \
-P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \
-t 'test/#' -v &
mosquitto_pub -h 127.0.0.1 -u frigate \
-P "$(grep ^FRIGATE_MQTT_PASSWORD .env | cut -d= -f2)" \
-t 'test/hello' -m 'ok'
# expect: test/hello ok
kill %1
#### Why both sides are required
Caddy and Authelia each control one half of the gate:
| What you configure | What it does |
|--------------------|-------------|
| `import authelia` in a Caddy site block | Sends that site's requests to Authelia for a decision |
| Rule in `access_control.rules` | Tells Authelia what decision to make |
The default policy is `deny`. If a request reaches Authelia with no matching rule, it gets a **403 Forbidden -- no login prompt, no redirect, just blocked**. This is true even for an already-logged-in user.
Miss either side and here is what happens:
| Caddy `import authelia` | Rule in `configuration.yml` | Result |
|------------------------|----------------------------|--------|
| Missing | Present | Site is open -- Authelia is never consulted |
| Present | Missing | 403 Forbidden, no login prompt |
| Both missing | | Site is open -- Authelia is never consulted |
| Both present | | Works correctly |
#### Which policy to use
| Policy | Requires |
|--------|---------|
| `bypass` | Nothing -- Authelia waves the request through. Used for the portal itself only. |
| `one_factor` | Password only |
| `two_factor` | Password + TOTP. Use this for everything. |
### 5. Create your first user
```bash
cp authelia/users_database.yml.example authelia/users_database.yml
```
```bash
$EDITOR authelia/users_database.yml
```
Fill in `username`, `email`, and `displayname`. Then generate the password hash:
```bash
docker compose run --rm authelia \
authelia crypto hash generate argon2
```
Authelia prompts for the password and a confirmation without echoing --
the plaintext never hits your shell history or `ps aux`. Copy the
`Digest: $argon2id$...` line from the output and paste the digest
(everything from `$argon2id` onward) as the `password:` value in
`users_database.yml`.
### 6. Pre-create the Authelia log file
Docker creates a **directory** at a bind-mount path if the source file does not exist yet. That breaks fail2ban's read-only mount. Create it as an empty file first:
```bash
touch authelia/authelia.log
```
### 7. Validate the config
```bash
docker compose run --rm authelia \
authelia validate-config --config /config/configuration.yml
```
Expect: `Configuration: validation complete` with no errors. Fix any YAML issues before continuing.
### 8. Wire Caddy
See [Wire Caddy into Authelia](#wire-caddy-into-authelia) below. Add the Caddy site block for each site alongside the rule you added in step 4.
### 9. Bring it up
```bash
# 7) Bring up the rest
docker compose up -d
docker compose logs -f
```
Confirm both services started cleanly:
Healthy startup looks like:
- Frigate: `frigate.comms.mqtt INFO : MQTT connected`
- frigate-notify: `Successfully connected to http://frigate:5000` then
`Connected to MQTT.` then `Subscribed to MQTT topic: frigate/events`
- mosquitto: incoming client connections from both
## Caddy
On whichever host runs Caddy, copy `caddy/Caddyfile` (or merge the relevant
site blocks into your existing one), edit IPs and domains, then:
```bash
docker compose logs -f authelia
sudo caddy validate --config /etc/caddy/Caddyfile
sudo systemctl reload caddy
```
Expect: `Authelia is listening on ...`
Until the Pi is deployed, comment out the `doorbell.yourdomain.com` block.
## Train face recognition
1. Let Frigate run with normal foot traffic for a day. Face crops are
captured automatically and appear under **Face Library -> Train** in
the Frigate UI.
2. For each person, label 5-10 **diverse** clear crops -- different angles,
lighting, times of day. Diversity beats quantity; 30 near-identical
frames from one event hurts recognition.
3. Walk past the camera again. Events should now show the person's name as
the sub-label, and frigate-notify's ntfy push will say their name
instead of "person".
Training images live on the Frigate host at
`/media/frigate/clips/faces/<n>/`. Not version-controlled (privacy).
## Switching config profiles
This repo ships with two Frigate configs:
- `frigate_config/config.yml` -- active. Main 2688x1520 stream for both
detect and record. Better face recognition at distance, higher CPU.
- `frigate_config/alternatives/config-simple.yml` -- lower-CPU fallback.
Sub-stream 640x480 for detect, main for record only.
To switch:
```bash
docker compose logs -f fail2ban
cp frigate_config/config.yml frigate_config/config.yml.bak
cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
docker compose restart frigate
```
Expect: `Jail authelia is now active`
## Adding a camera with a mic
## Wire Caddy into Authelia
When adding a camera with a built-in microphone (e.g. an Anpviz with mic),
see the comment block at the bottom of `frigate_config/config.yml` for the
exact edits needed to enable live audio in the Frigate UI.
Open `caddy/snippets.caddyfile`. It contains copy-paste blocks for your
existing Caddyfile, not a replacement for it:
## Pi doorbell speaker (when you're ready)
- `(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:
First, find your `caddy_net` subnet -- you need this for `trusted_proxies`:
```bash
docker network inspect caddy_net | jq '.[0].IPAM.Config'
```
Then edit `frigate_config/config.yml`:
```yaml
auth:
enabled: False
trusted_proxies:
- 172.18.0.0/16 # replace with your caddy_net subnet from above
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.
# auth_secret: 'your-32-byte-hex'
```
To generate the optional `auth_secret`:
```bash
openssl rand -hex 32
```
Set the same value in the `header_up X-Proxy-Secret` line in your Caddy site block.
Add the rule to `authelia/configuration.yml` (step 4 of first-run), then restart both services:
```bash
docker compose restart authelia
```
```bash
docker compose restart frigate # in your Frigate stack
```
Verify in a private browser window: `https://cam.yourdomain.com` should go 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
```
Authelia prompts for the password (no echo, not in shell history). Paste
the printed digest 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 file:
```bash
echo 'your_smtp_password' > authelia/secrets/SMTP_PASSWORD
chmod 600 authelia/secrets/SMTP_PASSWORD
```
Add to the `authelia` service `environment:` block in `docker-compose.yml`:
```yaml
- AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE=/secrets/SMTP_PASSWORD
```
Restart and check for a successful startup message:
```bash
docker compose restart authelia && docker compose logs -f authelia
```
Expect: `Notifier SMTP startup check successful`
See `pi/README.md`. Summary:
1. Flash Pi OS Lite, scp `pi/` to the Pi, run `install.sh`.
2. Add the `doorbell.yourdomain.com` site block in Caddy.
3. Open the URL on your phone.
## 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.
- `.env` has RTSP credentials, MQTT password, ntfy URL. Never commit it.
This repo's `.gitignore` blocks it; review `git status` before committing.
- The Pi's Flask server binds only to `127.0.0.1`. Caddy is what exposes it.
Do NOT bind `server.py` to `0.0.0.0` -- it has no auth of its own.
- Mosquitto's port 1883 is LAN-only. Use a VPN for any remote MQTT clients.
- For the doorbell page, optionally add basic auth in Caddy (`caddy
hash-password`).
## 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` -- a request reached Authelia with no matching rule for that domain. Add a rule under `access_control.rules` in `authelia/configuration.yml`:
```yaml
- domain: 'yoursite.{{ env "DOMAIN" }}'
policy: 'two_factor'
```
Then restart Authelia:
### Frigate can't reach cameras
```bash
docker compose restart authelia
docker compose exec frigate ping -c 2 <camera_ip>
```
### Authelia container restarts forever
RTSP path varies by camera vendor:
- Dahua / Amcrest: `/cam/realmonitor?channel=1&subtype=0`
- Hikvision / Anpviz H-series: `/Streaming/Channels/101`
If your password contains `%`, `@`, `/`, `?`, `#`, `&`, or `+`, either
URL-encode it or change the password to avoid those characters.
### Mosquitto restarts in a loop
Almost always permission on `mosquitto/config/passwd`:
```bash
sudo chown -R 1883:1883 mosquitto/
sudo chmod 0640 mosquitto/config/passwd
docker compose restart mosquitto
```
Or the config file or password file simply doesn't exist yet -- see the
"First-run on the Frigate host" section above for the bootstrap flow.
### frigate-notify connects to MQTT but doesn't send pings
```bash
docker compose logs authelia | head -50
docker compose logs frigate-notify --tail 30
```
Most often: missing/empty secret files in `authelia/secrets/`, bad YAML in
`configuration.yml`, or an invalid argon2 hash in `users_database.yml`.
- "webapi" in logs but expecting MQTT? Check `webapi.enabled: false` and
`mqtt.enabled: true` in `frigate-notify/config.yml`.
- MQTT auth fails? Verify `FN_FRIGATE__MQTT__PASSWORD` (note DOUBLE
underscores) matches what you set with `mosquitto_passwd`.
- Confirm what reached the container:
`docker inspect frigate-notify --format '{{range .Config.Env}}{{println .}}{{end}}' | grep FN_`
### Caddy can't resolve `authelia`
### Always see "person" instead of trained name
Caddy isn't on `caddy_net`. Add this to your Caddy service in its compose file:
- Increase `alerts.general.recheck_delay` in `frigate-notify/config.yml`
from 10 to 15 or 20 seconds.
- Check the Frigate UI event timeline -- if the event itself doesn't
show a sub_label, the face crop was too small / too blurry / too
obscured for recognition.
```yaml
services:
caddy:
networks:
- caddy_net
## Hardware reference
networks:
caddy_net:
external: true
```
Current:
- NVR host: x86_64 + Docker, USB Coral
- Cameras: Amcrest (Dahua RTSP)
- Notifier: self-hosted ntfy
Then recreate the Caddy container:
```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).
Planned:
- Anpviz 4K camera with built-in mic (front door audio)
- Back door + squirrel feeder cameras
- Pi Zero W + USB speaker at the door
-241
View File
@@ -1,241 +0,0 @@
---
###############################################################################
# Authelia configuration
#
# - File-based user database (no LDAP)
# - SQLite local storage (no Redis, no MySQL/Postgres)
# - Filesystem notifier (writes "emails" to /config/notifications/...)
# - Argon2id password hashing
# - Per-domain access policies under access_control.rules
#
# Secrets are NOT in this file. They are loaded from files mounted at
# /secrets via the AUTHELIA_*_FILE env vars in docker-compose.yml.
#
# Your domain comes from the DOMAIN variable in .env -- no manual
# find-and-replace needed. Authelia 4.38+ processes this file as a Go
# template, so {{ env "DOMAIN" }} is substituted at startup.
#
# After editing, validate before restarting:
# docker compose run --rm authelia authelia validate-config --config /config/configuration.yml
###############################################################################
theme: 'dark'
server:
address: 'tcp://0.0.0.0:9091'
buffers:
read: 8192
write: 8192
log:
level: 'info'
format: 'text' # fail2ban filter expects text format -- don't change to json
file_path: '/config/authelia.log'
keep_stdout: true # also log to stdout for `docker logs`
identity_validation:
reset_password:
jwt_lifespan: '5 minutes'
jwt_algorithm: 'HS256'
# jwt_secret loaded via AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
totp:
disable: false
issuer: '{{ env "DOMAIN" }}' # shown in your authenticator app
algorithm: 'sha1'
digits: 6
period: 30
authentication_backend:
password_change:
disable: false
password_reset:
disable: false
refresh_interval: '5 minutes'
file:
path: '/config/users_database.yml'
password:
algorithm: 'argon2'
argon2:
variant: 'argon2id'
iterations: 3
memory: 65536
parallelism: 4
key_length: 32
salt_length: 16
# ---------------------------------------------------------------------------
# Access control
#
# default_policy: deny -- every domain Caddy forward_auths here must have
# an explicit rule. Domains with no `import authelia` in Caddy never reach
# Authelia at all, so the deny doesn't apply to them.
#
# Policies:
# bypass Authelia waves the request through (used for the portal)
# one_factor password only
# two_factor password + TOTP
#
# ---------------------------------------------------------------------------
# WHICH SITES NEED A RULE HERE?
#
# CASE 1 -- App has NO built-in auth (e.g. Pi doorbell PTT page).
# -> Rule required + `import authelia` in Caddy.
# -> Authelia is the ONLY login. Use two_factor for hardware-control pages.
#
# CASE 2 -- App has built-in auth AND supports trusted-header proxy auth
# (Frigate 0.14+, Grafana, Gitea, Nextcloud, Home Assistant ...).
# -> Rule required + `import authelia` in Caddy + disable the app's login
# form in its own config (see README.md per-app instructions).
# -> Single Authelia login covers both access AND the app's user/role
# mapping via Remote-User / Remote-Groups headers.
#
# CASE 3 -- App has built-in auth and CANNOT switch to proxy auth, but you
# still want a 2FA gate in front of it (extra security layer).
# -> Rule required + `import authelia` in Caddy.
# -> User logs into Authelia (2FA) THEN into the app's own login form.
# Two separate logins -- the app auth is unchanged.
# -> Useful for: router admin pages, NAS UIs, any legacy app.
#
# CASE 4 -- App handles its own auth and you don't want Authelia involved.
# -> NO rule here + NO `import authelia` in Caddy.
# -> Traffic skips Authelia entirely; the app handles everything.
# ---------------------------------------------------------------------------
access_control:
default_policy: 'deny'
rules:
# The Authelia portal itself is always bypass.
- domain: 'auth.{{ env "DOMAIN" }}'
policy: 'bypass'
# ===================================================================
# CASE 1 -- No app auth. Authelia is the only gate.
# Example: Pi doorbell PTT page (Flask server, no built-in auth).
# two_factor is right -- this URL controls a speaker in your house.
#
# ALSO PASTE INTO CADDYFILE (case 1 site block):
# doorbell.{env.DOMAIN} {
# import accesslog
# import authelia
# handle_path /frigate/* {
# reverse_proxy 192.168.x.x:8971 # CHANGE: Frigate IP
# }
# handle {
# reverse_proxy 192.168.x.x:5555 # CHANGE: Pi IP
# }
# }
# ===================================================================
# - domain: 'doorbell.{{ env "DOMAIN" }}'
# policy: 'two_factor'
# ===================================================================
# CASE 2a -- App supports trusted-header proxy auth.
# Authelia replaces the app's login form. Single login; the app reads
# Remote-User from the upstream request for its own role mapping.
# Example: Frigate 0.14+.
#
# ALSO PASTE INTO CADDYFILE (case 2a site block):
# cam.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8971 { # CHANGE: Frigate IP
# transport http { read_timeout 60s; write_timeout 60s }
# # header_up X-Proxy-Secret "32-byte-hex" # if Frigate auth_secret set
# }
# }
#
# ALSO EDIT frigate_config/config.yml IN THE FRIGATE REPO:
# auth:
# enabled: False
# trusted_proxies: [172.18.0.0/16] # caddy_net subnet
# proxy:
# header_map: {user: remote-user, role: remote-groups}
# default_role: viewer
# separator: '|'
# ===================================================================
# - domain: 'cam.{{ env "DOMAIN" }}'
# policy: 'two_factor'
# ===================================================================
# CASE 2b -- App supports OIDC. Authelia is the OIDC provider.
# Caddy block is identical to case 2a; the difference is on the app
# side (token exchange, not header). REQUIRES additional setup of
# identity_providers.oidc below this access_control block, with one
# client per app -- see Authelia OIDC docs.
# Example: Audiobookshelf.
#
# ALSO PASTE INTO CADDYFILE (case 2b site block):
# books.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:13378 # CHANGE IP
# }
# ===================================================================
# - domain: 'books.{{ env "DOMAIN" }}'
# policy: 'two_factor'
# ===================================================================
# CASE 3 -- App keeps its own login. Authelia adds a 2FA gate in
# front. User authenticates with Authelia (2FA), then with the app
# itself. Two logins, but Authelia's 2FA covers apps that don't
# support proxy headers OR OIDC OR native TOTP.
# Example: Uptime Kuma, Portainer (without OIDC), router admin.
#
# ALSO PASTE INTO CADDYFILE (case 3 site block):
# uptime.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:3001 # CHANGE IP
# }
# ===================================================================
# - domain: 'uptime.{{ env "DOMAIN" }}'
# policy: 'two_factor'
# ===================================================================
# CASE 4 -- App handles its own auth. NO rule needed here.
# Authelia is never consulted. Caddy still imports accesslog so
# fail2ban watches the subdomain for scanners.
# Example: Plex/Emby (native clients break with Authelia redirects),
# Syncthing, anything you've decided to leave alone.
#
# ONLY PASTE INTO CADDYFILE -- nothing here in configuration.yml:
# plex.{env.DOMAIN} {
# import accesslog # NO import authelia
# reverse_proxy 192.168.x.x:32400 # CHANGE IP
# }
# ===================================================================
# (no rule -- case 4 is the absence of one)
session:
# secret loaded via AUTHELIA_SESSION_SECRET_FILE
cookies:
- name: 'authelia_session'
domain: '{{ env "DOMAIN" }}'
authelia_url: 'https://auth.{{ env "DOMAIN" }}'
default_redirection_url: 'https://{{ env "DOMAIN" }}'
expiration: '1 hour'
inactivity: '5 minutes'
remember_me: '1 month'
same_site: 'lax'
# In-app rate limiting. Locks the user account after repeated failures.
# fail2ban is the second line of defense: it bans the source IP.
# Together: Authelia locks the *user*, fail2ban bans the *IP*.
regulation:
max_retries: 3
find_time: '2 minutes'
ban_time: '5 minutes'
storage:
# encryption_key loaded via AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
local:
path: '/config/db.sqlite3'
# Filesystem notifier -- password reset / new device emails get written to
# a file you can `tail -f`. Swap to `smtp:` when you wire up a real
# transactional sender (see README.md "Switching the notifier to SMTP").
notifier:
disable_startup_check: false
filesystem:
filename: '/config/notifications/notification.txt'
-38
View File
@@ -1,38 +0,0 @@
---
###############################################################################
# Authelia users database
#
# Copy this to users_database.yml (gitignored) and edit. Generate each
# password hash with:
#
# docker compose run --rm authelia \
# authelia crypto hash generate argon2
#
# Authelia prompts for the password and a confirmation without echoing,
# so the plaintext never hits shell history or `ps aux`. Output ends
# with `Digest: $argon2id$v=19$m=...`. Paste the digest (everything from
# `$argon2id` onward) as the `password:` value below.
#
# Restart Authelia for changes to take effect (or wait refresh_interval).
###############################################################################
users:
yourname:
disabled: false
displayname: 'Your Name'
password: '$argon2id$v=19$m=65536,t=3,p=4$REPLACE_WITH_GENERATED_HASH'
email: 'you@example.com'
groups:
- 'admins'
# Add more users here. `groups` are referenced from access_control rules
# via `subject: 'group:admins'`.
#
# guest:
# disabled: false
# displayname: 'Guest'
# password: '$argon2id$v=19$m=65536,t=3,p=4$...'
# email: 'guest@example.com'
# groups:
# - 'guests'
+48
View File
@@ -0,0 +1,48 @@
# ---------------------------------------------------------------------------
# Caddyfile
#
# Install at /etc/caddy/Caddyfile (or merge with your existing one), edit the
# placeholders below, then: sudo systemctl reload caddy
#
# Caddy auto-provisions Let's Encrypt certs for every site block. DNS for
# both subdomains must point at this Caddy host's public IP first.
#
# Placeholders to replace:
# yourdomain.com -> your real domain
# 192.168.1.50 -> Frigate host LAN IP
# 192.168.1.60 -> Pi Zero W LAN IP (when you set up the doorbell)
# ---------------------------------------------------------------------------
# ---------- Frigate authenticated UI ----------
cam.yourdomain.com {
encode zstd gzip
reverse_proxy 192.168.1.50:8971 {
transport http {
read_timeout 60s
write_timeout 60s
}
}
}
# ---------- Doorbell PTT page (Pi Zero W) ----------
# Comment out this whole block until the Pi is deployed.
doorbell.yourdomain.com {
encode zstd gzip
# Same-origin proxy to Frigate so the browser's WebRTC fetch works
# without CORS issues. /frigate/* is stripped before forwarding.
handle_path /frigate/* {
reverse_proxy 192.168.1.50:8971 {
transport http {
read_timeout 60s
write_timeout 60s
}
}
}
# Everything else (HTML page + /audio WebSocket) goes to the Pi.
handle {
reverse_proxy 192.168.1.60:5555
}
}
-484
View File
@@ -1,484 +0,0 @@
# =============================================================================
# Authelia + fail2ban -- Caddy snippets
#
# These are SNIPPETS TO ADD to your existing Caddyfile, not a replacement
# for it. Copy the (authelia) and (accesslog) snippet definitions once at
# the top of your Caddyfile, then copy whichever site blocks apply.
#
# DOMAIN is read from the environment -- set it wherever your Caddy reads
# env vars (Caddy's own .env, systemd EnvironmentFile, or compose env:).
# Only the upstream IPs need manual editing.
#
# Caddy v2.5.1+ required; tested on v2.11.2.
# =============================================================================
# -----------------------------------------------------------------------------
# Paste these two snippet definitions once, near the top of your Caddyfile.
# -----------------------------------------------------------------------------
# Gate any site block with Authelia by adding `import authelia` inside it.
(authelia) {
forward_auth authelia:9091 {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Email Remote-Name
}
}
# JSON access log that fail2ban's caddy-4xx jail reads.
# Add `import accesslog` to EVERY site block (gated or not) so fail2ban
# catches scanners hitting all your subdomains, not just the protected ones.
(accesslog) {
log {
output file /var/log/caddy/access.log {
roll_size 10MiB
roll_keep 5
roll_keep_for 720h
}
format json
}
}
# =============================================================================
# Authelia portal -- always required; never put `import authelia` here.
# =============================================================================
auth.{env.DOMAIN} {
import accesslog
reverse_proxy authelia:9091
}
# =============================================================================
# CASE 1 -- App has NO built-in auth. Authelia is the only gate.
#
# Examples: Homer, Heimdall, Dozzle, Prometheus, Alertmanager, Gatus,
# WatchYourLAN, NUT web UI. Use two_factor in
# authelia/configuration.yml for any of these.
# =============================================================================
# Homer / Heimdall dashboard (no auth whatsoever)
homer.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:8080 # CHANGE IP:PORT
}
# Dozzle (Docker log viewer -- no auth by default)
dozzle.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:8888 # CHANGE IP:PORT
}
# --- Prometheus ---
# No authentication built in. Always gate it -- metrics expose internal details.
# prom.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:9090 # CHANGE IP
# }
# --- Alertmanager ---
# No authentication built in.
# alerts.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:9093 # CHANGE IP
# }
# --- Gatus ---
# Status / uptime page. Has optional built-in OIDC but simpler to gate here.
# status.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
# }
# --- WatchYourLAN ---
# Network ARP scanner. No built-in auth.
# lan.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8840 # CHANGE IP
# }
# --- NUT (Network UPS Tools) web UI ---
# The NUT daemon (upsd) has no web UI itself. Common frontends -- NUT-Monitor,
# upsd-web, various Docker images -- have minimal or no auth. Gate whichever
# you run here. Port varies by image.
# ups.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:PORT # CHANGE IP:PORT
# }
# --- Pi doorbell PTT page (Flask server, no auth) ---
# Uncomment when the Pi is deployed.
# doorbell.{env.DOMAIN} {
# import accesslog
# import authelia
#
# handle_path /frigate/* {
# reverse_proxy 192.168.x.x:8971 # CHANGE: Frigate IP
# }
# handle {
# reverse_proxy 192.168.x.x:5555 # CHANGE: Pi IP
# }
# }
# =============================================================================
# CASE 2a -- App supports trusted-header proxy auth. Authelia replaces its
# own login form. Single login, app reads Remote-User for roles.
#
# Requires per-app config changes -- see notes in each block.
# =============================================================================
# --- Frigate 0.14+ ---
# In frigate_config/config.yml:
# auth:
# enabled: False
# trusted_proxies: [172.18.0.0/16] # caddy_net subnet
# proxy:
# header_map: {user: remote-user, role: remote-groups}
# default_role: viewer
# separator: '|'
cam.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:8971 { # CHANGE IP
transport http { read_timeout 60s; write_timeout 60s }
# header_up X-Proxy-Secret "32-byte-hex" # if auth_secret: set in Frigate
}
}
# --- Grafana ---
# In grafana.ini (or GF_* env vars):
# [auth.proxy]
# enabled = true
# header_name = Remote-User
# header_property = username
# auto_sign_up = true
grafana.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:3000 # CHANGE IP
}
# --- Gitea / Forgejo ---
# In app.ini:
# [service]
# ENABLE_REVERSE_PROXY_AUTHENTICATION = true
# REVERSE_PROXY_TRUSTED_PROXIES = *
git.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:3000 # CHANGE IP
}
# --- Nextcloud ---
# In config/config.php:
# 'trusted_proxies' => ['172.18.0.0/16'],
# 'overwriteprotocol' => 'https',
# Plus user_external app + HTTP header auth set to Remote-User.
cloud.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:80 { # CHANGE IP
header_up Host {upstream_hostport}
}
}
# --- Paperless-ngx ---
# In compose env:
# PAPERLESS_ENABLE_HTTP_REMOTE_USER=true
# PAPERLESS_HTTP_REMOTE_USER_HEADER_NAME=HTTP_REMOTE_USER
paperless.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:8000 # CHANGE IP
}
# --- Miniflux ---
# In compose env:
# AUTH_PROXY_HEADER=Remote-User
# AUTH_PROXY_USER_CREATION=true
miniflux.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:8080 # CHANGE IP
}
# --- BookStack ---
# In .env:
# AUTH_METHOD=http
# HTTP_AUTH_HEADER=Remote-User
# HTTP_AUTH_AUTO_INITIATE=true
# bookstack.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:80 # CHANGE IP
# }
# =============================================================================
# CASE 2b -- App supports OIDC. Authelia acts as the OIDC provider.
#
# What is OIDC? OpenID Connect is an identity protocol on top of OAuth 2.0.
# Authelia becomes the "identity provider" (IdP). Apps redirect users to
# auth.DOMAIN, Authelia authenticates them and issues a signed token (JWT),
# then redirects back. The app trusts the token instead of checking a password.
#
# The Caddy config is identical to case 2a: `import authelia` gates the request.
# The difference is all on the app side -- it does a token exchange with
# Authelia's OIDC endpoint rather than reading a Remote-User header.
#
# SETUP REQUIRED in authelia/configuration.yml:
# Add an identity_providers.oidc block with a client entry for each app.
# Each app gets its own client_id and client_secret.
# See: https://www.authelia.com/configuration/identity-providers/openid-connect/
#
# Result: users never set a password in the app itself. After OIDC is working,
# disable all local accounts in the app -- Authelia is the only credential.
# =============================================================================
# --- Audiobookshelf ---
# Native OIDC support. In Audiobookshelf Settings > Authentication:
# Enable OpenID Connect SSO
# Issuer URL: https://auth.DOMAIN
# Client ID / Secret: from identity_providers.oidc in configuration.yml
# Auto Register: on (creates user on first OIDC login)
# books.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:13378 # CHANGE IP
# }
# --- Jellyfin ---
# Requires the community SSO plugin (Jellyfin.Plugin.SSO).
# Install from the Plugin Catalogue, then configure OIDC pointing at Authelia.
# jellyfin.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8096 # CHANGE IP
# }
# --- Immich ---
# No proxy-header auth; OIDC is the only Authelia path.
# In Immich Admin > Authentication Settings > OAuth:
# Issuer URL: https://auth.DOMAIN
# Client ID / Secret: from configuration.yml
# Auto register: on
# photos.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:2283 # CHANGE IP
# }
# --- Mealie ---
# OIDC supported. Set in Mealie's admin OIDC settings.
# Alternatively, skip OIDC and use case 3 as a simpler gate.
# mealie.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:9000 # CHANGE IP
# }
# --- Portainer ---
# Has OIDC for full SSO -- configure under Settings > Authentication.
# Or use case 3 (below) as a simpler gate without OIDC config.
# portainer.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:9000 # CHANGE IP (OIDC version)
# }
# =============================================================================
# CASE 3 -- App keeps its own login. Authelia adds a 2FA gate in front.
#
# The user passes Authelia 2FA, then the app's own login appears.
# Use for apps that don't support proxy auth headers or OIDC, but you still
# want 2FA before they're even reachable.
# =============================================================================
# --- Uptime Kuma ---
# No proxy auth, no native TOTP -- Authelia is the only way to add 2FA.
uptime.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:3001 # CHANGE IP
}
# --- Portainer (simple gate, no OIDC) ---
portainer.{env.DOMAIN} {
import accesslog
import authelia
reverse_proxy 192.168.x.x:9000 # CHANGE IP
}
# --- Home Assistant ---
# Can also do case 2a via the trusted_networks auth provider + header forwarding.
# homeassistant.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8123 # CHANGE IP
# }
# --- Vaultwarden ---
# Has its own strong auth + native TOTP. Many skip Authelia here entirely
# and rely on Vaultwarden's own 2FA (totally valid). Or use case 3 as an
# extra gate if you want 2FA even before the login page loads.
# vault.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:80 # CHANGE IP
# }
# --- qBittorrent ---
# Web UI has its own auth. No proxy headers.
# torrent.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8080 # CHANGE IP:PORT
# }
# --- Plex ---
# DO NOT use `import authelia` with Plex/Emby. Plex is coupled to plex.tv
# cloud auth -- Authelia cannot replace it. More importantly, native clients
# (mobile, TV, desktop apps) connect via FQDN through Caddy but CANNOT
# complete Authelia's browser-redirect login flow (no cookie, no TOTP prompt).
# `import authelia` will break all native clients with a connection error.
#
# Correct approach: Case 4. Caddy terminates TLS and reverse proxies; Plex's
# own token auth handles access control. `import accesslog` keeps fail2ban
# watching the subdomain for scanners.
#
# plex.{env.DOMAIN} {
# import accesslog
# reverse_proxy 192.168.x.x:32400 # CHANGE IP -- NO import authelia
# }
# --- Emby ---
# Same situation as Plex: native clients go through Caddy but cannot handle
# Authelia's login redirect. Use Case 4 -- Caddy + TLS, Emby's own auth.
#
# emby.{env.DOMAIN} {
# import accesslog
# reverse_proxy 192.168.x.x:8096 # CHANGE IP -- NO import authelia
# }
# --- Gotify ---
# Notification server. Own auth, no proxy headers.
# gotify.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
# }
# --- ntfy ---
# Notification server. Token-based auth, no proxy headers.
# ntfy.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:80 # CHANGE IP
# }
# --- wg-easy ---
# WireGuard web UI. Own password, no proxy headers.
# NOTE: WireGuard clients connect directly to UDP 51820, not through Caddy.
# This gate only protects the web management UI.
# wg.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:51821 # CHANGE IP
# }
# --- Umami ---
# Web analytics. Own auth, no proxy headers.
# analytics.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:3000 # CHANGE IP
# }
# --- phpIPAM ---
# IP address management. Own auth, no proxy headers.
# ipam.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:80 # CHANGE IP
# }
# --- Checkmk ---
# Monitoring. Own auth. No proxy headers in the free (Raw) edition.
# checkmk.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:5000 # CHANGE IP
# }
# --- Snipe-IT ---
# Asset management. Own auth, no proxy headers.
# assets.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:80 # CHANGE IP
# }
# --- Zammad ---
# Help desk / ticketing. Own auth, no proxy headers.
# tickets.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:3000 # CHANGE IP
# }
# --- Lubelog ---
# Vehicle maintenance log. Own auth, no proxy headers.
# cars.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8080 # CHANGE IP
# }
# --- UniFi Network Application ---
# Network controller. Own auth, no proxy headers (LDAP/RADIUS in enterprise).
# Serves HTTPS on 8443; skip TLS verify for the upstream.
# unifi.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:8443 { # CHANGE IP
# transport http { tls_insecure_skip_verify }
# }
# }
# --- MeshCentral ---
# Remote management server. Own auth; has OIDC in paid/enterprise builds.
# meshcentral.{env.DOMAIN} {
# import accesslog
# import authelia
# reverse_proxy 192.168.x.x:443 { # CHANGE IP
# transport http { tls_insecure_skip_verify }
# }
# }
# =============================================================================
# CASE 4 -- App handles its own auth. Authelia not involved.
# Still import accesslog so fail2ban covers this site.
# =============================================================================
# --- Syncthing ---
# sync.{env.DOMAIN} {
# import accesslog
# reverse_proxy 192.168.x.x:8384 # CHANGE IP
# }
# --- Router / NAS admin UI ---
# router.{env.DOMAIN} {
# import accesslog
# reverse_proxy 192.168.x.x:443 {
# transport http { tls_insecure_skip_verify }
# }
# }
+62 -80
View File
@@ -1,89 +1,71 @@
# ---------------------------------------------------------------------------
# Authelia + fail2ban
# Home camera stack
# - Frigate 0.17 (NVR + face recognition + LPR + audio detection)
# - Mosquitto (MQTT broker)
# - frigate-notify (event consumer -> ntfy push notifications)
#
# Self-hosted authentication portal (Authelia) plus an IP-banning sidecar
# (fail2ban). Sits next to your dockerized Caddy on the main server and
# joins the same external `caddy_net` so Caddy reaches Authelia by
# container name (`authelia:9091`). Authelia is NOT port-mapped to the
# host -- there is no reason for anything outside the docker network to
# hit it directly.
#
# fail2ban runs in host network mode so its iptables bans drop packets
# at the host edge, which is the only place the bans actually work for
# traffic destined for docker-published ports.
#
# First-run: see README.md.
# First-run setup: see README.md.
# ---------------------------------------------------------------------------
name: authelia
services:
authelia:
container_name: authelia
image: authelia/authelia:${AUTHELIA_VERSION:-4.39.19}
frigate:
container_name: frigate
image: ghcr.io/blakeblackshear/frigate:0.17.1
restart: unless-stopped
networks:
- caddy_net
expose:
- 9091
environment:
# Secrets are loaded from files mounted at /secrets (see volumes).
# The _FILE suffix is supported for any AUTHELIA_* env var.
- AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/secrets/JWT_SECRET
- AUTHELIA_SESSION_SECRET_FILE=/secrets/SESSION_SECRET
- AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/secrets/STORAGE_ENCRYPTION_KEY
- TZ=${TZ:-UTC}
# Enables Go-template substitution in configuration.yml so
# `{{ env "DOMAIN" }}` actually expands instead of being read as a
# literal string. Without this, Authelia parses the braces as part
# of the hostname and validate-config fails with `invalid character
# "{" in host name`. Inherited by `docker compose run --rm authelia`,
# so validate-config picks it up too.
- X_AUTHELIA_CONFIG_FILTERS=template
# Passed through so authelia/configuration.yml can use {{ env "DOMAIN" }}.
- DOMAIN=${DOMAIN}
volumes:
- ./authelia:/config
- ./authelia/secrets:/secrets:ro
healthcheck:
test: ['CMD', 'authelia', 'healthcheck']
interval: 30s
timeout: 5s
retries: 3
start_period: 30s
fail2ban:
container_name: fail2ban
image: crazymax/fail2ban:${FAIL2BAN_VERSION:-1.1.0-r0}
restart: unless-stopped
# Host networking so iptables bans take effect on the host's edge,
# including DOCKER-USER chain rules that gate traffic to containers.
network_mode: host
cap_add:
- NET_ADMIN
- NET_RAW
environment:
- TZ=${TZ:-UTC}
- F2B_LOG_LEVEL=INFO
- F2B_DB_PURGE_AGE=7d
volumes:
- ./fail2ban/data:/data
# Authelia text log -- fail2ban watches this for 1FA/TOTP failures.
# Authelia writes it to /config/authelia.log = ./authelia/authelia.log.
# IMPORTANT: `touch authelia/authelia.log` before first `docker compose up`
# so Docker creates it as a file, not a directory (see README.md).
- ./authelia/authelia.log:/var/log/authelia/authelia.log:ro
# Caddy JSON access log -- covers ALL sites that import (accesslog),
# not just Authelia-gated ones. Mount the host directory read-only.
# Your Caddy must write here; see README.md "Caddy access log path".
- /var/log/caddy:/var/log/caddy:ro
# Wait for Authelia to pass its healthcheck before starting, so the
# authelia.log file exists before fail2ban tries to bind-mount it.
stop_grace_period: 30s
privileged: true # needed for USB Coral
shm_size: "512mb"
env_file: .env
depends_on:
authelia:
condition: service_healthy
- mosquitto
devices:
- /dev/bus/usb:/dev/bus/usb # USB Coral
# - /dev/apex_0:/dev/apex_0 # PCIe Coral
# - /dev/dri/renderD128 # Intel/AMD hwaccel
volumes:
- /etc/localtime:/etc/localtime:ro
- ./frigate_config:/config
- /home/user/drives/sc-games/frigate:/media/frigate
- type: tmpfs
target: /tmp/cache
tmpfs:
size: 1000000000
ports:
- "8971:8971" # authenticated UI (proxied by Caddy)
- "5001:5000" # unauthenticated UI (LAN debug only)
- "8554:8554" # RTSP restream
- "8555:8555/tcp" # WebRTC TCP
- "8555:8555/udp" # WebRTC UDP
healthcheck:
test: ["CMD", "curl", "-f", "http://127.0.0.1:5000/api/version"]
interval: 10s
timeout: 5s
retries: 12
start_period: 60s
networks:
caddy_net:
external: true
mosquitto:
container_name: mosquitto
hostname: mosquitto
image: eclipse-mosquitto:2
restart: unless-stopped
ports:
- "1883:1883" # MQTT (LAN ONLY -- never expose to internet)
volumes:
- ./mosquitto/config:/mosquitto/config
- ./mosquitto/data:/mosquitto/data
- ./mosquitto/log:/mosquitto/log
frigate-notify:
container_name: frigate-notify
hostname: frigate-notify
image: ghcr.io/0x2142/frigate-notify:latest
restart: unless-stopped
env_file: .env
depends_on:
mosquitto:
condition: service_started
frigate:
condition: service_healthy
volumes:
- ./frigate-notify/config.yml:/app/config.yml:ro
-15
View File
@@ -1,15 +0,0 @@
# Matches Authelia's text-format log lines for failed authentication.
# Targets Authelia 4.38+. If you change `log.format` to `json` in
# authelia/configuration.yml, this regex needs updating.
#
# Test against a real log:
# docker compose exec fail2ban fail2ban-regex \
# /var/log/authelia/authelia.log \
# /data/filter.d/authelia.local
[Definition]
failregex = ^.*Unsuccessful (1FA|TOTP|Duo|U2F) authentication attempt by user.*remote_ip"?(:|=)"?<HOST>"?.*$
^.*user not found.*path=/api/reset-password/identity/start.*remote_ip"?(:|=)"?<HOST>"?.*$
ignoreregex =
-12
View File
@@ -1,12 +0,0 @@
# Bans IPs that spray 401/403/404/429 across many requests against Caddy.
# Targets Caddy's default JSON access log shape (one JSON object per line).
# Verify against a real log:
# docker compose exec fail2ban fail2ban-regex \
# /var/log/caddy/access.log \
# /data/filter.d/caddy-4xx.local
[Definition]
failregex = ^.*"remote_ip":"<HOST>".*"status":(401|403|404|429).*$
ignoreregex = ^.*"uri":"/(favicon\.ico|robots\.txt|apple-touch-icon[^"]*)".*$
-17
View File
@@ -1,17 +0,0 @@
[authelia]
enabled = true
filter = authelia
logpath = /var/log/authelia/authelia.log
maxretry = 3
findtime = 10m
bantime = 1h
# DOCKER-USER is the chain Docker inserts before its own per-container
# rules; banning here drops packets destined for docker-published ports
# (i.e. your Caddy container's 80/443) before iptables routes them in.
chain = DOCKER-USER
banaction = iptables-allports
# Tuple-form action so we record where it came from. `port=anyport` is
# fine because chain=DOCKER-USER drops at the chain head regardless.
action = iptables-allports[name=authelia, chain=DOCKER-USER]
-14
View File
@@ -1,14 +0,0 @@
[caddy-4xx]
enabled = true
filter = caddy-4xx
# Adjust if your Caddy writes elsewhere -- this must match the host path
# mounted into the fail2ban container in docker-compose.yml.
logpath = /var/log/caddy/access.log
maxretry = 30
findtime = 2m
bantime = 30m
chain = DOCKER-USER
banaction = iptables-allports
action = iptables-allports[name=caddy-4xx, chain=DOCKER-USER]
+153
View File
@@ -0,0 +1,153 @@
## frigate-notify config
## Docs: https://frigate-notify.0x2142.com
##
## Secrets and per-deployment values come from .env via the FN_* env vars.
## frigate-notify uses Viper-style env var lookup with DOUBLE underscores
## between YAML levels:
## frigate.server -> FN_FRIGATE__SERVER
## frigate.public_url -> FN_FRIGATE__PUBLIC_URL
## frigate.mqtt.password -> FN_FRIGATE__MQTT__PASSWORD
## alerts.ntfy.server -> FN_ALERTS__NTFY__SERVER
frigate:
server: # FN_FRIGATE__SERVER
ignoressl: true
public_url: # FN_FRIGATE__PUBLIC_URL
headers:
startup_check:
attempts: 5
interval: 30
webapi:
enabled: false # MQTT below is the primary event source
interval: 5
mqtt:
enabled: true
server: mosquitto # docker DNS name; constant for this stack
port: 1883
clientid: frigate-notify
username: frigate
password: # FN_FRIGATE__MQTT__PASSWORD
topic_prefix: frigate
cameras:
exclude:
alerts:
general:
# Title shows the recognized name (face recognition or LPR) when present,
# otherwise the generic label. Camera names are auto-titlecased by
# frigate-notify.
title: 'Frigate - {{ if .SubLabel }}{{ .SubLabel }}{{ else }}{{ .Label }}{{ end }} at {{ .Camera }}'
timeformat:
nosnap: allow
snap_bbox:
snap_timestamp:
snap_crop:
# Face recognition runs slightly AFTER the initial event. Without this
# delay, .SubLabel is empty and notifications always say "person".
recheck_delay: 10
quiet:
start:
end:
zones:
unzoned: allow
allow:
block:
labels:
min_score:
allow:
block:
sublabels:
# Once face recognition reliably IDs household members, list their names
# here to silence pings on them:
# block:
# - alex
# - bob
allow:
block:
discord:
enabled: false
webhook:
template:
gotify:
enabled: false
server:
token:
ignoressl:
template:
smtp:
enabled: false
server:
port:
tls:
user:
password:
recipient:
template:
telegram:
enabled: false
chatid:
token:
template:
pushover:
enabled: false
token:
userkey:
devices:
priority:
retry:
expire:
ttl:
template:
ntfy:
enabled: true
server: # FN_ALERTS__NTFY__SERVER
topic: "frigate"
ignoressl: false
# Priority + emoji change based on whether the person is recognized.
# Recognized -> priority 3 (normal) + wave; unknown -> priority 4 + alarm.
headers:
- X-Priority: '{{ if .SubLabel }}3{{ else }}4{{ end }}'
- X-Tags: '{{ if .SubLabel }}wave{{ else }}rotating_light{{ end }}'
template: |
{{ if .SubLabel -}}
{{ .SubLabel }} at {{ .Camera }}
{{- else -}}
{{ .Label }} at {{ .Camera }}
{{- end }}
{{ if gt (len .CurrentZones) 0 }}
Zone: {{ range $i, $z := .CurrentZones }}{{ if $i }}, {{ end }}{{ $z }}{{ end }}
{{- end }}
Score: {{ printf "%.0f" (mul .TopScore 100) }}%
Time: {{ .StartTime.Format "Mon 3:04 PM" }}
webhook:
enabled: false
server:
ignoressl:
headers:
template:
monitor:
enabled: false
url:
interval:
ignoressl:
@@ -0,0 +1,164 @@
##############################################################################
# Frigate 0.17 - SIMPLE (lower-CPU) variant
#
# Use if main config.yml uses too much CPU on your hardware.
#
# Differences vs main config.yml:
# - Sub-stream (640x480) used for detect; main stream for record only
# - Lower CPU: only the small sub-stream is decoded for detection
# - Face recognition still works for close-up faces; struggles at distance
# - LPR will rarely succeed (plate area too small in 640x480)
# - face_recognition.min_area lowered to 300 to catch smaller faces
#
# To activate:
# cp frigate_config/config.yml frigate_config/config.yml.bak
# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
# docker compose restart frigate
##############################################################################
version: 0.17-0
mqtt:
enabled: true
host: mosquitto
port: 1883
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
topic_prefix: frigate
client_id: frigate
stats_interval: 60
tls:
enabled: false
audio:
enabled: false
detectors:
coral:
type: edgetpu
device: usb
birdseye:
mode: continuous
semantic_search:
enabled: false
model_size: small
face_recognition:
enabled: true
model_size: small
min_area: 300 # smaller default since faces will be small on sub-stream
lpr:
enabled: true
model_size: small
classification:
bird:
enabled: false
objects:
track:
- person
record:
enabled: true
continuous:
days: 0
motion:
days: 10
alerts:
retain:
days: 360
mode: motion
detections:
retain:
days: 360
mode: motion
snapshots:
enabled: true
bounding_box: true
crop: true
retain:
default: 360
go2rtc:
streams:
front_door:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
front_door_sub:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
back_door:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
back_door_sub:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
squirrel:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
squirrel_sub:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
cameras:
front_door:
enabled: true
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/front_door_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
motion:
mask:
- 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467
- 0.984,0.614,0.513,0.99,0.991,0.996
- 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263
back_door:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/back_door
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/back_door_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
squirrel:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/squirrel
input_args: preset-rtsp-restream
roles:
- record
- path: rtsp://127.0.0.1:8554/squirrel_sub
input_args: preset-rtsp-restream
roles:
- detect
detect:
enabled: true
width: 640
height: 480
fps: 5
+202
View File
@@ -0,0 +1,202 @@
##############################################################################
# Frigate 0.17 production config
#
# - Main stream (2688x1520) used for detect + record on each camera
# (better face crops at distance, higher CPU)
# - Face recognition + LPR enabled (small models, CPU-friendly)
# - MQTT enabled for frigate-notify push events
#
# An alternate lower-CPU config that uses the sub-stream for detect lives at
# frigate_config/alternatives/config-simple.yml. To swap:
# cp frigate_config/config.yml frigate_config/config.yml.bak
# cp frigate_config/alternatives/config-simple.yml frigate_config/config.yml
# docker compose restart frigate
#
# Validate before restart:
# docker run --rm \
# -v $(pwd)/frigate_config/config.yml:/config/config.yml \
# --entrypoint python3 \
# ghcr.io/blakeblackshear/frigate:0.17.1 \
# -u -m frigate --validate-config
##############################################################################
version: 0.17-0
mqtt:
enabled: true
host: mosquitto
port: 1883
user: "{FRIGATE_MQTT_USER}"
password: "{FRIGATE_MQTT_PASSWORD}"
topic_prefix: frigate
client_id: frigate
stats_interval: 60
tls:
enabled: false
audio:
enabled: false # flip on when a mic-equipped camera arrives
detectors:
coral:
type: edgetpu
device: usb
birdseye:
mode: continuous
semantic_search:
enabled: false
model_size: small
face_recognition:
enabled: true
model_size: small
lpr:
enabled: true
model_size: small
# known_plates:
# owner:
# - "ABC-1234"
classification:
bird:
enabled: false
objects:
track:
- person
# ---------- global record defaults (0.17 schema) ----------
record:
enabled: true
continuous:
days: 0
motion:
days: 10
alerts:
retain:
days: 360
mode: motion
detections:
retain:
days: 360
mode: motion
snapshots:
enabled: true
bounding_box: true
crop: true
retain:
default: 360
# ---------- go2rtc: restream from cameras ----------
go2rtc:
streams:
front_door:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
front_door_sub:
- rtsp://{FRIGATE_RTSP_USER}:{FRIGATE_RTSP_PASSWORD}@{FRIGATE_FRONT_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
back_door:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
back_door_sub:
- rtsp://{FRIGATE_RTSP_USER1}:{FRIGATE_RTSP_PASSWORD1}@{FRIGATE_BACK_DOOR_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
squirrel:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=0#backchannel=0
squirrel_sub:
- rtsp://{FRIGATE_RTSP_USER2}:{FRIGATE_RTSP_PASSWORD2}@{FRIGATE_SQUIRREL_IP}:554/cam/realmonitor?channel=1&subtype=1#backchannel=0
# ---------- cameras ----------
cameras:
front_door:
enabled: true
ffmpeg:
inputs:
# Main stream used for both detect and record. Single connection,
# higher resolution -> better face recognition crops.
- path: rtsp://127.0.0.1:8554/front_door
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688 # adjust if your main stream is different
height: 1520
fps: 5 # detection doesn't need high fps; saves CPU
motion:
mask:
- 0.582,0.426,0.582,0.476,0.989,0.534,0.994,0.467
- 0.984,0.614,0.513,0.99,0.991,0.996
- 0.001,0.163,0.085,0.165,0.095,0.255,0.003,0.263
back_door:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/back_door
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688
height: 1520
fps: 5
squirrel:
enabled: false
ffmpeg:
inputs:
- path: rtsp://127.0.0.1:8554/squirrel
input_args: preset-rtsp-restream
roles:
- detect
- record
detect:
enabled: true
width: 2688
height: 1520
fps: 5
##############################################################################
# HOW TO ADD A CAMERA WITH A MIC (e.g., future Anpviz)
#
# 1. Set audio.enabled: true at the top of this file.
#
# 2. In go2rtc.streams, add the ffmpeg audio transcode line so live view
# has both AAC (for MSE) and opus (for WebRTC):
# anpviz:
# - rtsp://{FRIGATE_RTSP_USER3}:{FRIGATE_RTSP_PASSWORD3}@{FRIGATE_ANPVIZ_IP}:554/...#backchannel=0
# - "ffmpeg:anpviz#audio=aac#audio=opus"
#
# 3. In cameras, add the 'audio' role and set the audio-aware record preset:
# anpviz:
# enabled: true
# ffmpeg:
# output_args:
# record: preset-record-generic-audio-aac
# inputs:
# - path: rtsp://127.0.0.1:8554/anpviz
# input_args: preset-rtsp-restream
# roles:
# - detect
# - record
# - audio
#
# 4. Add FRIGATE_RTSP_USER3 / _PASSWORD3 / FRIGATE_ANPVIZ_IP to .env.
#
# 5. RTSP paths vary by vendor:
# Anpviz H-series (Hikvision OEM): /Streaming/Channels/101 (main), /102 (sub)
# Anpviz U-series (Dahua OEM): /cam/realmonitor?channel=1&subtype=0 (main)
# /cam/realmonitor?channel=1&subtype=1 (sub)
##############################################################################
+17
View File
@@ -0,0 +1,17 @@
# Mosquitto MQTT broker
#
# This config assumes the password file exists at /mosquitto/config/passwd.
# On first deployment, see README "First-run on the Frigate host" for how
# to bootstrap that file.
listener 1883 0.0.0.0
protocol mqtt
persistence true
persistence_location /mosquitto/data/
log_dest stdout
log_dest file /mosquitto/log/mosquitto.log
allow_anonymous false
password_file /mosquitto/config/passwd
+126
View File
@@ -0,0 +1,126 @@
# Pi doorbell PTT
Turns a Raspberry Pi into a network speaker so a phone hitting
`https://doorbell.yourdomain.com` can see/hear the front-door Frigate feed
and hold a button to talk through a speaker mounted at the door.
## Hardware
- Any Raspberry Pi (Zero W 1st gen is enough; Zero 2 W is better for live
two-way; Pi 3A+ has a 3.5mm jack onboard and skips the OTG adapter)
- Audio output, one of:
- USB speaker + micro-USB-to-USB-A OTG adapter (simplest)
- 3.5mm powered speaker (Pi 3A+ has the jack; Zero W does not)
- I2S DAC HAT (best quality, requires GPIO header)
- microSD card, power supply, WiFi or USB ethernet
## Install on the Pi
```bash
# From your laptop/desktop:
scp -r pi/ pi@PI_LAN_IP:~/doorbell-src
# SSH to the Pi:
ssh pi@PI_LAN_IP
cd ~/doorbell-src
chmod +x install.sh
./install.sh
```
The installer apt-installs ffmpeg + alsa-utils + Python deps, creates a
virtualenv, drops `server.py` into `~/doorbell/`, installs and enables the
systemd service, runs `speaker-test` to confirm ALSA output works, and
starts the service.
## Verify
```bash
curl http://127.0.0.1:5555/healthz # -> ok
sudo journalctl -u doorbell -f # live logs
```
## Wire it up
1. On the Caddy host, add the `doorbell.yourdomain.com` block from
`../caddy/Caddyfile` and reload Caddy.
2. DNS: point `doorbell.yourdomain.com` at the Caddy host's public IP.
3. Open `https://doorbell.yourdomain.com` on an Android phone.
4. Grant the one-time microphone permission.
5. Tap **Unmute camera** if browser autoplay swallowed the audio.
6. Hold the big green button to talk.
Add to home screen (Chrome menu -> Add to home screen) for an app-like
experience.
## Choosing the camera
Each Pi is hardcoded to one camera -- the one whose mic and speaker are
physically co-located with this Pi. The PTT button on this page only
talks to *this* Pi's speaker, so mixing cameras here would let a misclick
talk into the wrong room.
`server.py` near the top:
```python
CAMERA_NAME = "front_door"
```
The name must match a `go2rtc.streams` entry in
`frigate_config/config.yml` and the camera must be `enabled: true` in
Frigate. After editing:
```bash
sudo systemctl restart doorbell
```
## Multiple doorbell Pis
Run one copy of this app per Pi, each on its own subdomain (e.g.
`frontdoor.yourdomain.com`, `backdoor.yourdomain.com`). Add a Caddy
block per subdomain pointing at that Pi's LAN IP -- same shape as the
existing `doorbell.yourdomain.com` block in `../caddy/Caddyfile`.
To render quick-jump buttons to the other Pis at the top of the page,
fill in `PEER_LINKS` near the top of `server.py`:
```python
PEER_LINKS = [
{"label": "Back door", "url": "https://backdoor.yourdomain.com"},
{"label": "Squirrel", "url": "https://squirrel.yourdomain.com"},
]
```
Leave it as `[]` (the default) and the row is hidden. Restart with
`sudo systemctl restart doorbell` after editing.
## Audio stack
ALSA-only -- no PipeWire/PulseAudio. Lighter on the Pi Zero. If you ever
need PipeWire (e.g., to share the speaker with another app), change
`'-f', 'alsa'` to `'-f', 'pulse'` in `server.py` and install the
PipeWire/Pulse compatibility shim.
## Troubleshooting
### speaker-test fails
USB/3.5mm output isn't the default ALSA card. Check:
```bash
aplay -l
```
If your speaker isn't card 0, create `/etc/asound.conf`:
```
defaults.pcm.card 1
defaults.ctl.card 1
```
(Replace `1` with whatever card your speaker is.)
### Video plays but talk button stuck on "Disconnected"
The WebSocket isn't reaching the Pi. Most common: Caddy not proxying
`doorbell.yourdomain.com` -> Pi correctly. From the Caddy host:
```bash
curl -i http://PI_LAN_IP:5555/healthz # should return 200 ok
```
### Feedback loop when talking
The page auto-mutes the camera while the PTT button is held, so this
should not happen. If it does, increase distance between Pi speaker and
camera mic, or turn the speaker volume down.
+19
View File
@@ -0,0 +1,19 @@
[Unit]
Description=Doorbell PTT server
After=network-online.target sound.target
Wants=network-online.target
[Service]
Type=simple
User=pi
Group=audio
WorkingDirectory=/home/pi/doorbell
ExecStart=/home/pi/doorbell-venv/bin/python /home/pi/doorbell/server.py
Restart=always
RestartSec=3
NoNewPrivileges=true
PrivateTmp=true
[Install]
WantedBy=multi-user.target
+53
View File
@@ -0,0 +1,53 @@
#!/usr/bin/env bash
# Pi Zero W setup for the doorbell PTT server.
# Run as the 'pi' user after flashing Raspberry Pi OS Lite (Bookworm).
#
# Usage:
# chmod +x install.sh
# ./install.sh
set -euo pipefail
echo ">>> Installing OS packages..."
sudo apt update
sudo apt install -y ffmpeg alsa-utils python3-venv python3-pip
echo ">>> Creating project dirs..."
mkdir -p "$HOME/doorbell"
echo ">>> Creating Python virtualenv..."
python3 -m venv "$HOME/doorbell-venv"
# shellcheck disable=SC1091
source "$HOME/doorbell-venv/bin/activate"
pip install --upgrade pip
pip install flask flask-sock
echo ">>> Copying server.py..."
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
cp "$SCRIPT_DIR/server.py" "$HOME/doorbell/server.py"
echo ">>> Installing systemd service..."
sudo cp "$SCRIPT_DIR/doorbell.service" /etc/systemd/system/doorbell.service
sudo systemctl daemon-reload
sudo systemctl enable doorbell
echo ">>> Testing audio output..."
echo "You should hear 'front left' in a moment. Ctrl-C if nothing plays."
speaker-test -D default -c 2 -t wav -l 1 || {
echo "!! speaker-test failed. Fix ALSA output before starting the service."
echo " Try: sudo raspi-config -> System Options -> Audio"
echo " Or: aplay -l and edit /etc/asound.conf"
exit 1
}
echo ">>> Starting doorbell service..."
sudo systemctl restart doorbell
sleep 2
sudo systemctl status doorbell --no-pager
echo
echo "=========================================="
echo "Done. Quick checks:"
echo " curl http://127.0.0.1:5555/healthz"
echo " sudo journalctl -u doorbell -f"
echo "=========================================="
+269
View File
@@ -0,0 +1,269 @@
#!/usr/bin/env python3
"""
Doorbell PTT server for Pi Zero W.
Serves a single-page web app that:
* shows the Frigate WebRTC live feed (video + camera mic if present)
* provides a push-to-talk button that streams phone mic audio over a
WebSocket; this script decodes and plays it out ALSA.
Deployment:
* listens on 127.0.0.1:5555; expose publicly via Caddy reverse proxy
* runs under systemd as the 'pi' user
* requires: python3-flask, flask-sock, ffmpeg, alsa-utils
"""
import subprocess
from flask import Flask, render_template_string
from flask_sock import Sock
app = Flask(__name__)
sock = Sock(app)
# Camera this Pi corresponds to. Must match a go2rtc stream name in
# frigate_config/config.yml. The PTT button talks to the speaker physically
# attached to this Pi, so this should be the camera at the same location.
CAMERA_NAME = "front_door"
# Optional jump-links to sibling doorbell Pis (each running its own copy of
# this app, hardcoded to its own camera). Rendered as a row of buttons above
# the PTT button when non-empty. Leave empty if there are no other Pis.
PEER_LINKS = [
# {"label": "Back door", "url": "https://backdoor.yourdomain.com"},
]
PAGE = """<!doctype html>
<html lang="en">
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1,user-scalable=no,viewport-fit=cover">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="mobile-web-app-capable" content="yes">
<title>Doorbell</title>
<style>
*{box-sizing:border-box}
html,body{margin:0;padding:0;height:100%;background:#000;color:#fff;
font-family:system-ui,-apple-system,sans-serif;overflow:hidden;
touch-action:none;-webkit-user-select:none;user-select:none}
#wrap{display:flex;flex-direction:column;height:100vh;height:100dvh}
#video{flex:1;min-height:0;background:#000;position:relative}
video{width:100%;height:100%;object-fit:contain;background:#000}
#controls{padding:16px;display:flex;flex-direction:column;gap:10px;
background:#111;padding-bottom:max(16px,env(safe-area-inset-bottom))}
#ptt{font-size:24px;padding:28px;border:none;border-radius:14px;
background:#2d6a2d;color:#fff;font-weight:700;touch-action:none;
transition:background .05s,transform .05s}
#ptt.active{background:#d33;transform:scale(.98)}
#ptt:disabled{background:#333;color:#666}
.row{display:flex;gap:8px}
.row button{flex:1;padding:10px;background:#333;color:#fff;border:none;
border-radius:8px;font-size:13px}
.row a{flex:1;padding:10px;background:#333;color:#fff;border-radius:8px;
font-size:13px;text-decoration:none;text-align:center;
display:flex;align-items:center;justify-content:center}
#status{font-size:12px;color:#888;text-align:center;min-height:1em}
</style>
</head>
<body>
<div id="wrap">
<div id="video">
<video id="cam" autoplay playsinline muted></video>
</div>
<div id="controls">
<div class="row" id="peers" style="display:none"></div>
<button id="ptt" disabled>Connecting...</button>
<div class="row">
<button id="unmute">Unmute camera</button>
<button id="wake">Keep screen on</button>
<button id="reload">Reconnect</button>
</div>
<div id="status"></div>
</div>
</div>
<script>
// ---- CONFIG ---------------------------------------------------------
const CAMERA_NAME = {{ camera_name|tojson }};
const PEERS = {{ peers|tojson }};
const FRIGATE_WEBRTC_URL = "/frigate/api/go2rtc/api/webrtc?src=" + encodeURIComponent(CAMERA_NAME);
// --------------------------------------------------------------------
const $ = id => document.getElementById(id);
const ptt = $('ptt'), status = $('status'), video = $('cam'),
unmute = $('unmute'), reload = $('reload'), wake = $('wake'),
peersRow = $('peers');
let ws, mediaRecorder, micStream, wakeLock = null;
const log = m => { status.textContent = m; console.log('[doorbell]', m); };
if (PEERS.length) {
for (const p of PEERS) {
const a = document.createElement('a');
a.href = p.url;
a.textContent = p.label;
peersRow.appendChild(a);
}
peersRow.style.display = 'flex';
}
async function startVideo(){
try {
const pc = new RTCPeerConnection();
pc.addTransceiver('video', {direction:'recvonly'});
pc.addTransceiver('audio', {direction:'recvonly'});
pc.ontrack = e => { video.srcObject = e.streams[0]; };
pc.oniceconnectionstatechange = () => log('ICE: ' + pc.iceConnectionState);
const offer = await pc.createOffer();
await pc.setLocalDescription(offer);
const resp = await fetch(FRIGATE_WEBRTC_URL, {
method: 'POST',
headers: {'Content-Type': 'application/sdp'},
body: pc.localDescription.sdp,
credentials: 'include'
});
if(!resp.ok) throw new Error('Frigate returned ' + resp.status);
const answer = await resp.text();
await pc.setRemoteDescription({type:'answer', sdp: answer});
log('Camera connected');
} catch(e){ log('Video error: ' + e.message); }
}
unmute.onclick = () => {
video.muted = !video.muted;
unmute.textContent = video.muted ? 'Unmute camera' : 'Mute camera';
if(!video.muted) video.play().catch(()=>{});
};
reload.onclick = () => location.reload();
wake.onclick = async () => {
if(!('wakeLock' in navigator)){ log('Wake lock not supported'); return; }
if(wakeLock){
wakeLock.release(); wakeLock = null;
wake.textContent = 'Keep screen on';
} else {
try {
wakeLock = await navigator.wakeLock.request('screen');
wake.textContent = 'Screen locked on';
wakeLock.addEventListener('release', () => {
wake.textContent = 'Keep screen on'; wakeLock = null;
});
} catch(e){ log('Wake lock failed: ' + e.message); }
}
};
async function setupPTT(){
try {
micStream = await navigator.mediaDevices.getUserMedia({
audio: {echoCancellation: true, noiseSuppression: true, autoGainControl: true}
});
const proto = location.protocol === 'https:' ? 'wss:' : 'ws:';
ws = new WebSocket(proto + '//' + location.host + '/audio');
ws.binaryType = 'arraybuffer';
ws.onopen = () => {
ptt.disabled = false;
ptt.textContent = 'Hold to talk';
log('Ready');
};
ws.onclose = () => {
ptt.disabled = true;
ptt.textContent = 'Disconnected';
log('WebSocket closed -- tap Reconnect');
};
ws.onerror = () => log('WebSocket error');
} catch(e){ log('Mic permission error: ' + e.message); }
}
function startTalking(e){
if(!ws || ws.readyState !== WebSocket.OPEN) return;
e.preventDefault();
ptt.classList.add('active');
ptt.textContent = 'TALKING';
video.muted = true; // prevent feedback loop
mediaRecorder = new MediaRecorder(micStream, {mimeType:'audio/webm;codecs=opus'});
mediaRecorder.ondataavailable = ev => {
if(ev.data.size > 0 && ws.readyState === WebSocket.OPEN){
ev.data.arrayBuffer().then(buf => ws.send(buf));
}
};
mediaRecorder.start(100);
}
function stopTalking(e){
e && e.preventDefault();
if(mediaRecorder && mediaRecorder.state === 'recording'){
mediaRecorder.stop();
}
ptt.classList.remove('active');
ptt.textContent = 'Hold to talk';
video.muted = false;
video.play().catch(()=>{});
}
ptt.addEventListener('touchstart', startTalking, {passive:false});
ptt.addEventListener('touchend', stopTalking, {passive:false});
ptt.addEventListener('touchcancel', stopTalking, {passive:false});
ptt.addEventListener('mousedown', startTalking);
ptt.addEventListener('mouseup', stopTalking);
ptt.addEventListener('mouseleave', stopTalking);
startVideo();
setupPTT();
</script>
</body>
</html>
"""
@app.route('/')
def index():
return render_template_string(PAGE, camera_name=CAMERA_NAME, peers=PEER_LINKS)
@app.route('/healthz')
def healthz():
return 'ok'
@sock.route('/audio')
def audio(ws):
ff = subprocess.Popen(
[
'ffmpeg',
'-loglevel', 'error',
'-f', 'webm', '-i', 'pipe:0',
'-f', 'alsa', 'default',
],
stdin=subprocess.PIPE,
stdout=subprocess.DEVNULL,
stderr=subprocess.DEVNULL,
)
try:
while True:
data = ws.receive()
if data is None:
break
if isinstance(data, (bytes, bytearray)):
try:
ff.stdin.write(data)
ff.stdin.flush()
except BrokenPipeError:
break
finally:
try:
ff.stdin.close()
except Exception:
pass
try:
ff.terminate()
ff.wait(timeout=2)
except Exception:
ff.kill()
if __name__ == '__main__':
# 127.0.0.1 only -- Caddy reverse-proxies from the public domain
app.run(host='127.0.0.1', port=5555, threaded=True)