_authelia_provision_oidc_client() hardcoded openid/profile/email as the
only scopes a registered client could ever request, but Homebox's own
Authelia integration needs 'groups' too — requesting it without it being
in the client's own scopes allowlist made Authelia reject every login
with invalid_scope, even though the server supports 'groups' generally.
Add an EXTRA_SCOPES positional arg (space-separated, right after
REQUIRE_PKCE) that only Homebox's caller populates ("groups"); every
other existing caller passes "" and gets a byte-for-byte unchanged
client registration.
60 KiB
CLAUDE.md — ubuntu-post-install contributor guide
Context for adding or modifying services. Read this before touching any service file so the result matches what's already here.
How the system works
setup.sh sources lib/common.sh then globs every services/*.sh file.
Each service file self-registers and defines its install function. Nothing
in setup.sh needs to change when you add a service — just add the file.
The wizard groups services by category (from register_service), shows a
checklist per group, and calls install_<name>() for each selected item.
--list, --dry-run, and --unattended all work automatically.
Adding a service — the three-step rule
- Create
services/<name>.sh(kebab-case filename) - Call
register_serviceat the top of the file - Define
install_<name>()— keep hyphens literal in the function name (install_pstn-trunk, notinstall_pstn_trunk).setup.sh's dispatcher callsinstall_${name}with no hyphen→underscore conversion, so the function name must match the service name exactly. Confirmed live: a mismatched underscore here producesService 'x' has no install_xat runtime, not a load-time error.
That's it. The menu picks it up on the next run.
Also update the Services table in README.md — add the service name to the
appropriate group row so the README stays current.
Retiring a service name (merging two services)
Deleting services/<name>.sh removes it from the menu, but sudo ./setup.sh <name> then fails outright for anyone with that name in their notes, docs, or
shell history. Add the old name to SERVICE_ALIAS in setup.sh instead —
run_service resolves it to the surviving service, says so once, and runs
that. The alias never gets its own menu entry, which is the whole point.
services/asterisk-digital-ocean.sh was merged into services/asterisk.sh
this way: one installer that detects a DigitalOcean droplet (metadata service,
with a y/n either way) and applies the droplet-only extras — swapfile,
public-FQDN-only flow, hand-built Caddy site block, remote Authelia, Cloud
Firewall — behind that one answer. Two lessons worth reusing:
- Don't rename a live install's directory or containers. New installs
land in
~/docker/asteriskwith a container namedasterisk; a pre-merge droplet keeps~/docker/asterisk-digital-oceanandeasy-asterisk-do, because its Caddyfile block, UFW rules, Cloud Firewall, CrowdSec acquisition and PSTN trunk all name those exact paths._asterisk_resolve_layout()picks whichever directory exists, and every sibling service probes both. The plain container name was itself renamed once already — fromeasy-asterisk(this repo's original choice, reusing the vendor CLI tool's own name,/usr/local/bin/easy-asteriskinside the container — unrelated, never renamed) to plainasterisk, matching every other service's owncontainer_name == service nameconvention. The same "don't rename under a running deployment" rule applied: every resolver (_asterisk_resolve_layout()and the duplicated copies insecurity-dashboard.sh,sms-inbound.sh,pstn-trunk.sh,tools/pstn-test-check.sh) reads the container name out of the box's owndocker-compose.ymlinstead of assuming it, so an existingeasy-asteriskinstall keeps working unchanged. Migrating one to the new name is a deliberate, one-time action on that box (editdocker-compose.yml'scontainer_name:for both Asterisk and its coturn sidecar,docker compose down+up -d) — once done, every sibling service re-reads it from that same file and follows automatically. - Check whether a "flavor-specific" behavior was actually flavor-specific.
The Asterisk security-logging patch and the
logs/fulllogrotate config were droplet-only purely because that's where they got written first — the Security Dashboard's Security Log and CrowdSec's Asterisk acquisition were silently empty on every home/LAN install as a result. Both now apply everywhere.
The pre-merge installer is parked at attic/asterisk-digital-ocean.sh as a
rollback path until the unified one is confirmed on real hardware. attic/
is outside setup.sh's services/*.sh glob, so nothing there registers or
runs on its own — see attic/README.md, including why it's a way to get the
old script back rather than an undo button. Delete it once the merge is
proven; a second copy of the same logic is what the merge existed to remove,
and fixes are deliberately not backported into it.
Minimal Docker service template
#!/bin/bash
# services/my-tool.sh — One-line description.
# Part of the modular post-install system (sourced by setup.sh).
register_service my-tool utilities "What it does (My Tool)" 8080
install_my_tool() {
require_docker || return 1
local DIR="$DOCKER_DIR/my-tool"
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would create $DIR with docker-compose.yml"
return 0
fi
mkdir -p "$DIR"
ensure_docker_dir_ownership "$DIR"
cd "$DIR" || return 1
cat > docker-compose.yml << 'EOF'
name: my-tool
services:
my-tool:
image: vendor/my-tool:latest
container_name: my-tool
restart: unless-stopped
ports:
- "8080:8080"
volumes:
- ./data:/data
EOF
configure_caddy_for_service "My Tool" "8080" "my-tool"
write_readme "$DIR" << 'MD'
# My Tool
Brief description.
## Manage
```bash
docker compose up -d
docker compose down
docker compose logs -f
docker compose pull && docker compose up -d
MD
local START=""
prompt_yn "Start My Tool now? (y/n):" "y" START
if [ "$START" = "y" ] || [ "$START" = "Y" ]; then
docker compose up -d \
&& log_success "My Tool started" \
|| log_warning "Start failed — check: docker compose logs"
fi
}
## register_service signature
```bash
register_service <name> <group> "<description>" [port]
name— kebab-case, matches the filename and theinstall_functiongroup— one of the categories below; determines which menu it appears indescription— shown in--listand the menu checklistport— optional; informational only (not used by the framework)
Available globals
| Variable | Value |
|---|---|
DOCKER_DIR |
~/docker — parent for all Docker service directories |
ACTUAL_USER |
The non-root user that invoked sudo |
ACTUAL_HOME |
Home directory of ACTUAL_USER |
SITE_TZ |
Timezone from site config, e.g. America/New_York |
SITE_DOMAIN |
Base domain from site config, e.g. example.com |
SITE_CADDY_NET |
Docker network name for Caddy (default: caddy_net) |
DRY_RUN |
true/false — set by --dry-run flag |
UNATTENDED |
true/false — set by --unattended flag |
Available helpers (lib/common.sh)
Logging
log_info "message" # blue [INFO]
log_success "message" # green [OK]
log_warning "message" # yellow [WARN]
log_error "message" # red [ERROR]
Prompts — honor UNATTENDED automatically
prompt_yn "Question? (y/n):" "default_y_or_n" VARNAME
prompt_text "Question? [default]:" "default" VARNAME
prompt_reinstall_mode VARNAME # sets VARNAME to: update | fresh | cancel
When UNATTENDED=true all three skip the prompt; prompt_yn/prompt_text use
their given default, prompt_reinstall_mode always resolves to cancel. See
Update vs. fresh reinstall on rerun below for how to use the latter.
Pre-flight
require_root # exits with an error if not running as root
require_docker # installs Docker CE + Compose plugin if missing, then returns
Execution and ownership
run_cmd COMMAND [args...] # no-ops in DRY_RUN, executes otherwise
ensure_docker_dir_ownership DIR... # chown -R ACTUAL_USER:ACTUAL_USER (skips in DRY_RUN)
generate_password [length] # alphanumeric random string, default 32 chars
pip_user_install PACKAGE... # pip3 --user with --break-system-packages on 24.04+
Caddy reverse proxy
configure_caddy_for_service "Display Name" "PORT" "default-subdomain" ["extra-block"] ["reverse_proxy-extra"]
Prompts the user for a domain, appends a site block to the Caddyfile, and
reloads Caddy. No-ops silently if Caddy isn't installed. The fourth argument
is an optional string inserted verbatim inside the Caddy site block, before
reverse_proxy (use it for import authelia or custom matchers). The fifth
argument is a different thing — an optional string inserted inside the
reverse_proxy block itself, as sub-directives (e.g.
" header_up X-Proxy-Secret abc123"), for a backend that needs a
header only reverse_proxy's own header_up can set — the fourth
argument's block runs before reverse_proxy and can't reach into it.
services/frigate.sh is the reference caller: Frigate's proxy auth mode
trusts Remote-User/Remote-Groups headers from Authelia's forward_auth,
but only if a matching X-Proxy-Secret header is also present — otherwise
those headers could be spoofed by a request that reaches Frigate's
published host port directly, bypassing Caddy/Authelia entirely. Omit the
fifth argument and the generated reverse_proxy line is the same bare form
as before — every other caller is unaffected.
The function places that block before reverse_proxy in the generated
site block — don't reorder this. forward_auth (what import authelia
expands to) is the same directive family as reverse_proxy internally, and
Caddy doesn't reorder repeats of the same directive within a block; it runs
them in the order they're written. reverse_proxy written first would
handle and terminate every request immediately, making an auth check
written after it dead code that never runs — full bypass regardless of what
the auth server's own access-control rules say. Confirmed live: this was
the actual cause of a "Caddy proxies fine but Authelia never prompts for
login" bug, on a site block that otherwise looked completely correct. If a
service builds its own site block instead of using this helper (e.g.
services/asterisk.sh does in droplet mode, deliberately — see
_asterisk_configure_caddy_public's comment for why), put its auth block
first there too.
forward_auth to a remote Authelia over a scheme-qualified URL needs
explicit header_up pins. A bare forward_auth authelia:9091 (Authelia on
the same Docker network, one hop) is fine relying on Caddy's default
X-Forwarded-* headers. But forward_auth https://auth.example.com { ... }
(Authelia on a different machine, reached over its own public domain+TLS —
see services/asterisk.sh's droplet-mode remote-Authelia prompt) is a
second Caddy hop: Caddy rewrites the outgoing request's Host header to
auth.example.com so the remote Caddy can route/SNI-match it, and without an
override X-Forwarded-Host picks up that rewritten value instead of the
original site's host. Confirmed live: Authelia evaluated every protected
domain as if the request were for auth.example.com itself (which typically
has policy: bypass in access_control.rules so its own login portal isn't
gated behind itself) — so every domain behind the remote instance silently
passed through with no 2FA prompt, regardless of that domain's own policy.
Fix: pin the forwarded headers to the original request explicitly instead of
trusting Caddy's default derivation:
forward_auth https://auth.example.com {
uri /api/authz/forward-auth
copy_headers Remote-User Remote-Groups Remote-Name Remote-Email
header_up X-Forwarded-Method {method}
header_up X-Forwarded-Proto {scheme}
header_up X-Forwarded-Host {host}
header_up X-Forwarded-Uri {uri}
}
This only affects the remote-Authelia path — same-machine authelia:9091
snippets (services/authelia.sh) are a single hop and don't need it.
Sets three out-params (not local — read them after the call returns) so
the caller can tell whether Caddy actually ended up fronting the service:
CADDY_SERVICE_CONFIGURED # true/false
CADDY_SERVICE_MODE # "local" or "remote" (only meaningful if configured)
CADDY_SERVICE_DOMAIN # the domain actually configured (only meaningful if configured)
CADDY_SERVICE_DOMAIN is what _authelia_scope_access() (see below) wants
as its DOMAIN argument — read it right after the call instead of
recomputing/guessing the domain a second time.
Use this to skip opening a host firewall port for a service Caddy already
fronts locally (it reaches the service over host.docker.internal, not
the network) — but still open it when CADDY_SERVICE_MODE is "remote",
since a remote Caddy machine needs to reach this host over the network
instead. See services/asterisk.sh for the reference pattern: it decides
the Caddy question before building firewall rules, not after, so the
answer is known in time (in droplet mode it hand-builds its own site block
and sets the same flag itself, for the reasons noted above).
UFW enable
ensure_ufw_enabled
Call this after your service has already added its own ufw allow
rules — it only flips UFW from inactive to active, it doesn't add rules for
you. No-ops if UFW is already active or not installed. Always allows SSH
first (reading the real port from sshd_config in case it's non-default)
before enabling, so this can't lock out the session running the installer.
Closing a port to the internet without also closing it to Caddy
ufw_allow_from_caddy_net PORT [PROTO] # PROTO defaults to tcp
When CADDY_SERVICE_MODE is "local" (see above) and you ufw delete allow a port because Caddy fronts it now, don't stop there — UFW rules
apply to all interfaces unless scoped, and Caddy's own request to
host.docker.internal:PORT is ordinary INPUT-chain traffic arriving over
the caddy_net bridge, not the public internet. A bare ufw delete allow
blocks that too and silently breaks the service (confirmed live: closing
the web admin port outright took Caddy down with it). Call
ufw_allow_from_caddy_net right after the delete to re-open the port
scoped to just caddy_net's subnet — reachable from Caddy, not from the
internet. See services/asterisk.sh for the pattern.
README generation
write_readme "$DIR" << 'MD'
# Title
Content
MD
Writes $DIR/README.md (creates the directory if needed). No-ops in DRY_RUN.
Every Docker service should call this so ~/docker/<name>/README.md is
self-documenting on the deployed box.
Companion doc files. If services/<name>.md exists next to
services/<name>.sh, write_readme appends its contents automatically —
no per-service code needed to opt in, just add the file. It's outside
setup.sh's services/*.sh glob so it never registers or runs on its own;
it's purely markdown that gets tacked onto the generated README. Use it for
walkthroughs that don't depend on anything chosen at install time (linking
steps in a third-party UI, multi-account setup, troubleshooting notes) —
content like that bloats the heredoc without adding anything install-specific.
Keep the heredoc for content that does depend on install-time values (the
actual port chosen, generated credentials, etc.); use services/<name>.md
for everything else. See services/traccar.md for the reference example.
Categories
| Group | Purpose |
|---|---|
base |
CLI packages installed on every box |
homelab |
Core infrastructure — reverse proxy, auth, intrusion prevention |
utilities |
Self-hosted web apps — budget, DNS, files, monitoring, VPN, etc. |
media |
Media servers, photo backup, disc ripping |
cameras |
NVR and camera tooling (Frigate) |
gaming |
Game servers, cloud gaming (Wolf), emulation |
extras |
Non-Docker tools and scripts |
backup |
Backup solutions |
Authelia SSO — which services need it
Some services have their own login screens; others have none and need Caddy to gate them via Authelia.
Protecting more than one apex domain from the same box — same instance, not a second
one. services/authelia.sh, re-run against an existing install, offers "Add another
protected domain to this instance": it appends a new access_control.rules entry and a new
session.cookies entry (both are YAML lists — Authelia natively supports multiple
independent cookie scopes) plus a Caddy auth.<domain> portal block for the new domain, all
on the same Authelia + Redis container. Each domain gets its own login/session (no
cross-domain SSO between them) and shares one user database, without the RAM cost of a
second full Authelia+Redis stack — the right choice whenever the domains are going to live
on the same machine anyway. See add_authelia_domain() in services/authelia.sh.
Running a genuinely separate instance (e.g. one per machine). services/authelia.sh
runs standalone on any box (sudo bash authelia.sh, same pattern as crowdsec.sh) and
asterisk.sh already auto-detects a local install (if [ -d "$DOCKER_DIR/authelia" ]), switching from the remote-Authelia forward_auth flow to the
local import authelia snippet automatically — so a second, fully independent instance on
another machine (e.g. a droplet, for resilience if the first machine goes down) works with
no code changes. Use this instead of the same-instance approach above when the two domains
are on different machines, not just different domains on one machine.
The one real constraint for genuinely separate instances: Authelia's session cookie is
scoped to AUTHELIA_DOMAIN (the apex domain entered at install time) with
includeSubDomains-style matching, and the portal itself lives at auth.${AUTHELIA_DOMAIN}.
Two independent instances must not share the same AUTHELIA_DOMAIN. If they did, both
would try to claim the same auth.<domain> hostname (DNS can only point that at one
machine) and the same cookie scope with completely separate session stores — users bouncing
between subdomains fronted by different instances would see confusing repeated logins as
each instance's cookie gets overwritten/rejected by the other's. Give each instance either a
genuinely separate apex domain, or a distinct subdomain tree the other instance doesn't also
claim. (This constraint doesn't apply to the same-instance, multiple-domains approach above —
each domain there gets its own cookie entry by design, which is exactly what avoids the
collision.)
Has built-in auth — no Authelia needed:
emby, jellyfin, audiobookshelf, immich, mealie, actualbudget,
homeassistant, portainer, meshcentral, traccar, uptimekuma,
filebrowser, wg-easy, ntfy (configurable), gitea
Native OIDC login as an addition, not a Caddy gate — gitea's pattern.
Some apps with their own built-in login also have their own "add an
OAuth2/OIDC provider" setting — a genuinely different integration from
both the forward_auth gate above and the "Enable OpenID" client-registration
flow below. services/gitea.sh's _gitea_offer_authelia_sso() is the
reference: if Authelia is installed, offers to register Gitea as an OIDC
client (via services/authelia.sh's _authelia_provision_oidc_client() —
the same non-interactive, out-param-returning core that
_authelia_add_oidc_client()'s menu flow uses) and then runs gitea admin auth add-oauth itself to add Authelia as an authentication source — no
manual web-UI copy-paste on either side, matching this repo's "no manual
wizard" philosophy elsewhere in gitea.sh (admin account/token creation).
Local login keeps working unchanged; this only adds an extra button on the
existing login page. Reuse _authelia_provision_oidc_client() (guarded by
declare -F, same convention as chaining into another service's
install_<name>()) for any future service with its own native OIDC field,
instead of duplicating Authelia's client-secret-generation/config-patching
logic again.
Internal vs. outside access — named, reusable groups, not one group per
service. By default, any domain with an access_control rule at all is
"internal": reachable by every Authelia user (the existing catch-all
*.${AUTHELIA_DOMAIN} rule) — admins included automatically, since the
admin-bypass rule (below) always outranks it anyway. services/authelia.sh's
_authelia_scope_access(SERVICE_ID, DOMAIN) is the generic, reusable opt-in
on top of that for "outside access" — call it right after any service
finishes being protected by Authelia, forward_auth gate or native OIDC alike
(it only cares about the domain, not the gating mechanism; see
_gitea_offer_authelia_sso() for the reference caller). Asks "Internal
(default) or Outside access", and if outside access, lets the admin pick an
existing named group (by number, so e.g. "customer1" can be attached to a
second, third, unrelated site later) or type a new one — service_id is
only the suggested default name, never forced. Creates the group if new
(adding every listed username to it, creating accounts on the fly via
_authelia_create_user_noninteractive() for names that don't exist yet,
printing their temp password) and inserts two rules above the general
catch-all but below the admin-bypass rule — allow that group on this
domain, deny that group on every other protected domain. The already-scoped
check is keyed to the (domain, group) pair, not the group name alone, so
reusing a group on a second site correctly adds that site's own rule instead
of a false "already scoped" no-op (a real bug in an earlier version of this
function, since fixed).
Three more menu options round this out: 13 backfills the admin-bypass rule (below) onto any apex domain missing it; 14 renames a group everywhere it's referenced (rules + every member); 15 lists every group's sites and members in one place; 16 is the reverse of the scoping prompt's own member-picker — pick a group first, then toggle which users are in it, for adding members without re-touching a site.
Admins always match first, on every domain — old sites and new.
_authelia_ensure_admin_bypass(config_file, domain) inserts
- domain: "*.${domain}" / subject: "group:admins" / policy: two_factor as
literally the first rule under rules:, and every insertion point that
adds new rules (add_authelia_domain, _authelia_scope_access) inserts
after this block rather than at the literal top of rules:, so a later
scoping action can never accidentally outrank it. Without this, a group's
deny-elsewhere rule (above) would deny an admin who's ever added to that
group on every OTHER domain — this rule exists specifically so that can't
happen. install_authelia()/add_authelia_domain() bake it in for
anything created from here on; menu option 13 backfills it onto an
instance that predates the feature.
Guard every cross-file call with declare -F, same convention as the OIDC
helper above — a service can run standalone with authelia.sh never sourced.
_authelia_report_access_scope() (menu option 6 on an existing Authelia
install) is the read side: lists who has universal access versus who's
scoped to which service(s), and offers to promote a scoped user back to
universal by removing them from their -only group(s) — a pure users.yml
edit, since universal access is just the absence of a restricting group,
not a rule of its own.
services/gitea.sh, services/mealie.sh, and services/actualbudget.sh
call _authelia_scope_access() so far. The other services that already
offer a plain "Protect X with Authelia SSO?" prompt (magicmirror,
wolf-pair, js99er, drum-rhythm-game, iopaint, paintplus,
stirling-pdf, wolf) are natural, mechanical follow-ups — each just
needs one added call to _authelia_scope_access after its existing
configure_caddy_for_service step, once Gitea's integration has been
confirmed working live.
Native OIDC support across the "has built-in auth" list — checked against each app's real docs (2026-08), not assumed. Don't extend this pattern to a service without checking its own current settings first — two of the ones below turned out to need actual verification to get right (Portainer, ntfy), not general familiarity with the product:
| Service | Native OIDC? | Notes |
|---|---|---|
mealie |
Yes — wired up | Pure env vars (OIDC_AUTH_ENABLED, OIDC_CLIENT_ID/SECRET, OIDC_CONFIGURATION_URL), see _mealie_offer_authelia_oidc(). Redirect URI is <BASE_URL>/login. Needs a --forwarded-allow-ips entrypoint override when Caddy-fronted, or the generated redirect URI comes out http:// even when actually served over https:// — see the function's own comment. |
homebox |
Yes — wired up | Pure env vars (HBOX_OIDC_ENABLED, HBOX_OIDC_ISSUER_URL, HBOX_OIDC_CLIENT_ID/SECRET, HBOX_OIDC_SCOPE), see _homebox_offer_authelia_oidc(). Confirmed against homebox.software's own OIDC docs and authelia.com's Homebox integration page — needs PKCE (unlike Mealie/ActualBudget). Redirect path is /api/v1/users/login/oidc/callback; issuer URL is reportedly sensitive to a trailing slash (a real upstream bug), so it's written from this repo's own portal-URL value as-is, never with one appended. The stock compose template didn't have env_file: .env (vars were listed individually in environment: instead) — added to the template, and patched onto any pre-existing install's compose file the first time this offer runs, or the written .env additions would silently never reach the container. HBOX_OPTIONS_ALLOW_LOCAL_LOGIN=false/HBOX_OIDC_AUTO_REDIRECT=true are real, documented env vars for fully replacing local login, offered as a separate step gated behind the same "have you tested the button first" confirmation as Mealie/Beszel. Unlike every other native-OIDC integration in this table, HBOX_OIDC_SCOPE needs a fourth scope, groups, alongside the usual openid profile email — Authelia's own Homebox integration page documents this. Confirmed live: requesting it without also granting it broke login outright (invalid_scope: "The OAuth 2.0 Client is not allowed to request scope 'groups'"), because Authelia enforces a per-client scopes allowlist independent of what the server supports overall — _authelia_provision_oidc_client() used to hardcode openid/profile/email for every caller with no way to add more. Fixed by giving it a 6th positional arg, EXTRA_SCOPES (space-separated, inserted right after REQUIRE_PKCE), that every other existing caller passes as "" — Homebox's is the only caller that passes "groups". |
actualbudget |
Yes — wired up | Pure env vars (ACTUAL_OPENID_DISCOVERY_URL, ACTUAL_OPENID_CLIENT_ID/SECRET, ACTUAL_OPENID_SERVER_HOSTNAME), see _actualbudget_offer_authelia_oidc(). Redirect path /openid/callback (matches the existing preset in _authelia_add_oidc_client()'s menu). First OIDC login becomes the server owner if none is set yet — Actual's own behavior. |
immich |
Yes — wired up | Real OAuth2/OIDC settings under Administration → Settings, backed by GET/PUT /api/system-config — confirmed the exact JSON field names against Immich's own config-file.md and source directly (the oauth sub-object: enabled/issuerUrl/clientId/clientSecret/scope/buttonText, etc.), not guessed. See _immich_offer_authelia_oidc(). GET/PUT exchange the whole config object (no partial-patch endpoint), so it round-trips everything else — storage template, library settings — completely unchanged; the same shape already proven by import-photos.sh's own storage-template step in this file. Needs an admin API key, which doesn't exist until the user creates their account on first web visit — this offer runs from both the fresh-install path (usually a no-op that first time) and the "update" rerun path, which is the realistic way most people finish this. |
audiobookshelf |
Yes — wired up (Authelia side only) | Checked against audiobookshelf.org's own OIDC docs: config is UI-only (Settings → Authentication), no env var or config API — so _audiobookshelf_offer_authelia_oidc() registers the Authelia client (needs PKCE, confirmed via authelia.com's own integration page for it) and prints the exact individual-endpoint values to paste in, since Audiobookshelf wants those rather than a discovery URL. Three redirect URIs: web callback, mobile-redirect, and the audiobookshelf://oauth app-scheme callback. |
beszel |
Yes — wired up (Authelia side only) | PocketBase-based; its OAuth2 provider is a PocketBase admin-UI setting (Settings → Auth providers), not an API — checked against beszel.dev directly. _beszel_offer_authelia_oidc() registers the Authelia client (also needs PKCE, per authelia.com's Beszel integration page) and prints the paste-in values. Separately offers the real, documented DISABLE_PASSWORD_AUTH/USER_CREATION env vars to fully replace Beszel's own login — gated behind an explicit warning to register a working account first, since Beszel has no default account and no signup-fallback if that hasn't happened yet. |
jellyfin |
Only via a third-party plugin | No official native OIDC. Community plugins exist (jellyfin-plugin-sso, jellyfin-plugin-oidc) but are web-UI-only — native mobile/desktop Jellyfin clients can't use them. A bigger lift than an env-var toggle (plugin install via Jellyfin's own plugin repo system); hold off until that's worth doing deliberately. |
homeassistant |
Only via a third-party HACS integration | No native core OIDC as of 2026 (open community discussion asking for it, not shipped). hass-oidc-auth/hass-openid exist as HACS-installed integrations — same "bigger lift" caveat as Jellyfin. |
portainer |
No (CE) | OAuth/OIDC is a Business Edition feature — this repo installs portainer-ce (confirmed in services/portainer.sh), which doesn't have it. CE's documented path is fronting it with oauth2-proxy, i.e. no different from the forward_auth pattern any no-built-in-auth service already uses — not "native OIDC" in the sense this section means. |
ntfy |
No | Checked ntfy's own config docs directly — no auth-oauth2-* keys exist. Only basic auth + access tokens + ACLs. (Worth a re-check on a future ntfy release if this matters to you — this class of feature does get added to self-hosted tools over time.) |
emby, meshcentral, traccar, uptimekuma, filebrowser, wg-easy |
Not individually re-verified | High-confidence no, based on general familiarity with each product rather than a fresh doc check this pass (unlike everything above, which was actually checked and in two cases contradicted assumption). Verify before wiring any of these in, the same way the checked ones were — don't extrapolate from this table's pattern. |
No built-in auth — should be protected:
magicmirror, wolf-pair, js99er, drum-rhythm-game, iopaint,
paintplus, stirling-pdf, wolf (web UI). Each of these prompts
"Protect X with Authelia SSO? (y/n)" and passes import authelia as
configure_caddy_for_service's extra block when accepted.
security-dashboard is Authelia-protected unconditionally (not asked —
baked into its own Caddy block, since it exposes Asterisk/CrowdSec
data). asterisk offers the same protection for its web admin, with a
choice between a local import authelia and a remote forward_auth (see
the remote-Authelia note earlier in this file).
sky-cam was previously listed here but has no Caddy integration or web
login of any kind — it's a non-Docker batch/cron script that renders
timelapse videos and posts them to Mattermost, so there's nothing on it
for Authelia to protect. Removed from this list; if it grows a web UI in
the future, add it back and wire up the same prompt other services here
use.
frigate — a third pattern, neither of the two above. Frigate does
have built-in auth (username/password, admin/viewer roles, on by
default) so it isn't "no built-in auth" — but unlike the has-built-in-auth
list, that auth is designed to be handed off to an upstream proxy instead
of just living alongside it. Frigate has its own proxy auth mode built
specifically for Authelia/Authentik/oauth2_proxy/traefik-forward-auth:
given trusted Remote-User/Remote-Groups headers it can skip its own
login screen entirely (auth.enabled: False), rather than showing a
second, independently-expiring login after Authelia's. services/frigate.sh
wires this up: import authelia (fourth arg) plus a
header_up X-Proxy-Secret <secret> (fifth arg, see
configure_caddy_for_service above) into the reverse_proxy block, with
the matching proxy.auth_secret/header_map/default_role: admin block
written into config/config.yml — and only written at all once
CADDY_SERVICE_CONFIGURED confirms Caddy actually ended up fronting the
domain, so Frigate's own login is never disabled with nothing else in
front of it. default_role: admin (default in this repo's install) means
anyone who passes Authelia gets full access, same as the login it
replaces; use proxy.role_map/Authelia groups instead if some users
should be view-only. Reuses the same FRIGATE_PROXY_AUTH_SECRET on
reinstall (from .env via ENV_MAP, the same array _frigate_parse_existing
already builds) rather than rotating it and breaking the existing Caddy
pairing.
gitea and uptimekuma — two more "disable/bypass built-in login,
Authelia is the only gate" integrations, each with its own trust model.
Both are opt-in extras layered on top of the has-built-in-auth entries
those services already had; neither replaces the existing behavior for
anyone who doesn't ask for it.
gitea's_gitea_offer_reverse_proxy_auth()is a second, stronger Authelia integration alongside the OIDC "Sign in with Authelia" button (_gitea_offer_authelia_sso(), unchanged): Gitea's ownENABLE_REVERSE_PROXY_AUTHENTICATIONmode auto-logs in as whatever username arrives in a trusted header — no click, no separate Gitea session with its own expiry. Unlike Frigate, Gitea's own login page isn't disabled — it stays as a fallback for anyone not arriving through the trusted path, so there's no "native login off with nothing gating it" failure mode to guard against here. The trust boundary isREVERSE_PROXY_TRUSTED_PROXIES(an IP range), not a shared secret — Gitea's own Docker image has shipped this wildcarded before (a real CVE, GHSA-f75j-4cw6-rmx4: any source IP could setX-WEBAUTH-USERand log in as anyone), so this always computes the range from caddy_net's actual subnet (docker network inspect ... --format '{{range .IPAM.Config}}{{.Subnet}}{{end}}', the same lookupufw_allow_from_caddy_netuses) and refuses to enable the feature at all if that can't be determined — never falls back to a permissive default.REVERSE_PROXY_AUTHENTICATION_USER/_EMAILare set toRemote-User/Remote-Emailto match Authelia'simport autheliasnippet's owncopy_headersoutput directly, rather than renaming headers in Caddy to match Gitea's ownX-WEBAUTH-USERdefault. Gitea currently reaches Caddy over its published host port (host.docker.internal:PORT), not caddy_net, because it predates this feature — enabling it rewires Gitea onto caddy_net (like every other locally-Caddy-fronted service) and re-points Caddy's upstream atgitea:3000, replacing the old site block viaconfigure_caddy_for_service's own existing "already exists — overwrite?" prompt. Local Caddy only; a remote Caddy machine's source address isn't a stable, narrowly-scopeable range the way caddy_net's bridge subnet is.uptimekuma's equivalent is much simpler: Uptime Kuma'sDISABLE_AUTH=trueenv var turns its own login off completely, with no IP-range or secret check left at all — once set, anything that can reach its port is in, no questions asked. That makes it the one of these three where getting the ordering wrong is worst:services/uptimekuma.shonly ever setsDISABLE_AUTH=trueafterconfigure_caddy_for_service "Uptime Kuma" "uptime-kuma:3001" "uptime" " import authelia"confirmsCADDY_SERVICE_CONFIGURED— the same never-disable-native-auth- without-a-confirmed-gate rule Frigate follows. Uptime Kuma already joined caddy_net unconditionally before this (see its own_CADDY_NET_BLOCK), so no networking change was needed here, just the env var and the Authelia-gated Caddy call happening earlier (beforedocker-compose.ymlis written) instead of the plain unconditional call this file already had at the end — which now only runs as a fallback when the Authelia path wasn't used or wasn't completed.
For services without built-in auth, prompt the user before calling
configure_caddy_for_service and pass import authelia as the extra block
if Authelia is installed and the user wants SSO protection:
local EXTRA_BLOCK=""
if [ -d "$DOCKER_DIR/authelia" ]; then
local _use_auth=""
prompt_yn "Protect MagicMirror with Authelia SSO? (y/n):" "y" _use_auth
[[ "$_use_auth" =~ ^[Yy]$ ]] && EXTRA_BLOCK=" import authelia"
fi
configure_caddy_for_service "MagicMirror" "8081" "mirror" "$EXTRA_BLOCK"
Authelia "stay logged in" / kiosk mode:
install_authelia() already writes remember_me: 7d into
configuration.yml at install time — the checkbox is on the login form
from day one, this is only about how long checking it actually lasts.
To change the duration later, use the menu instead of hand-editing the
file: re-run sudo ./setup.sh authelia against an existing install and
pick "Change 'remember me' session duration" (_authelia_set_remember_me()
in services/authelia.sh) — prompts for a new duration (12h, 7d,
1M, 1y, or -1 to disable Remember Me entirely) and restarts.
Sessions persist through reboots regardless of duration (Redis stores
session state in a volume).
The config key is remember_me, not remember_me_duration. Authelia
renamed it in 4.38; this repo pins 4.39.20. A stale remember_me_duration
key doesn't error, Authelia just silently ignores it — confirmed against
Authelia's own docs/changelog after this file's own example used the old
name for a while without anyone noticing, since nothing here actually
reads it back to verify the write took effect. If you ever do need to
touch this by hand instead of the menu option, the current schema is:
session:
secret: 'your-existing-secret'
expiration: 1h
inactivity: 5m
remember_me: 1y
cookies:
- domain: 'example.com'
authelia_url: 'https://auth.example.com'
This only covers Authelia's own session. A native-OIDC app
(gitea/mealie/actualbudget) issues its own separate session/token
after logging in via Authelia, with its own independent expiry — a long
remember_me makes re-authenticating to Authelia itself instant/silent
whenever that app's own session expires and bounces you back through the
OIDC flow, but it doesn't stop that app's session from expiring on its
own schedule. If a native-OIDC app logs users out sooner than expected,
that app's own session-length setting (if it exposes one) is the other
thing to check, not this one.
Non-Docker services
Not everything is a container. For apt-based or git-clone–based services,
skip require_docker and the Docker helpers. See services/base.sh (apt
packages + Charm repo) and services/crowdsec.sh (official apt repo) as
reference patterns.
For non-Docker services the default is_installed check in setup.sh
looks for $DOCKER_DIR/$name, which won't exist. Add a case to the
is_installed() function in setup.sh so the [installed] marker appears
correctly in the menu:
# In setup.sh → is_installed()
my-tool) command -v my-tool >/dev/null 2>&1 ;;
Docker services use the default case and don't need an entry.
DRY_RUN convention
Every install_* function must check $DRY_RUN before touching the
filesystem, installing packages, or starting containers. The pattern is:
if [ "$DRY_RUN" = true ]; then
echo "[DRY-RUN] Would do X"
echo "[DRY-RUN] Would do Y"
return 0
fi
Put the check early — after any pure-display output (banners, info text) but before the first write.
Update vs. fresh reinstall on rerun
Every service should detect an existing install at the top of its
install_<name>() — after the DRY_RUN check, before any prompts — and
offer prompt_reinstall_mode instead of silently re-running every prompt
(domain, secrets, firewall, Authelia, extras...) from scratch. What counts
as "already installed" is service-specific: usually docker-compose.yml and
.env both existing in the service's $DOCKER_DIR/<name> directory.
if [[ -f "$DIR/docker-compose.yml" && -f "$DIR/.env" ]]; then
local MODE=""
prompt_reinstall_mode MODE
case "$MODE" in
update)
# Refresh vendor files / config templates, rebuild, done.
# Do NOT touch .env, firewall rules, or Caddy/Authelia config.
...
return 0
;;
cancel)
log_info "Leaving the existing install as-is."
return 0
;;
fresh) ;; # fall through to the full install flow below
esac
fi
update should be genuinely non-destructive: refresh whatever the service
vendors or templates (Docker image sources, config templates,
docker-compose.yml) and rebuild/restart, but never touch .env, firewall
rules, or reverse-proxy/SSO config that's already in place. If the
vendor-copy or docker-compose.yml-generation logic is more than a few
lines, factor it into a helper function so the fresh-install path and the
update path share one copy instead of drifting apart — see
_asterisk_refresh_vendor_files/_asterisk_write_compose in
services/asterisk.sh for the reference pattern.
cancel must leave the install completely untouched — it's the default for
a reason (a stray Enter on a service you're just checking on shouldn't
trigger anything). fresh runs the exact same flow a first-time install
would, prompts included.
Multi-instance services
Any service where running two genuinely separate copies is a real use case
(two Mattermost teams, two WordPress sites, two Traccar fleets, a
music-only Emby alongside a movies one) should support it — this isn't
opt-in per service, it's the default shape for anything that stores its
own data and isn't inherently single-tenant (skip it for things like
caddy or crowdsec, where a second instance wouldn't mean anything).
The pattern (see services/mattermost.sh for the original, and
services/audiobookshelf.sh/services/emby.sh/services/mealie.sh/
services/traccar.sh/services/wordpress.sh for more examples): the first
instance keeps the plain name/directory/container/ports exactly as they'd
be without any of this — zero behavior change for anyone with a single
instance already installed. Only choosing to add a second introduces
suffixed naming. services/wordpress.sh is the one exception that requires
a name from every instance including the first — reasonable for a
brand-new service with no existing single-instance installs to stay
compatible with, but not the default choice for an established service.
local DIR="$DOCKER_DIR/myservice"
local INSTANCE_SUFFIX="" CONTAINER="myservice"
local WEB_PORT="9000"
if [ -d "$DIR" ]; then
echo ""
echo " MyService is already installed at $DIR."
echo " 1) Manage that install (update / full reinstall / cancel)"
echo " 2) Add a NEW, separate MyService instance alongside it (its own"
echo " server and data — full isolation)"
echo ""
local _TOP_CHOICE=""
prompt_text " Choice [1/2]:" "1" _TOP_CHOICE
if [ "$_TOP_CHOICE" = "2" ]; then
local _suffix=""
while true; do
prompt_text " Short name for the new instance (letters/numbers/hyphens):" "" _suffix
_suffix="$(echo "$_suffix" | tr -cs 'a-zA-Z0-9-' '-' | sed 's/^-*//;s/-*$//')"
[ -z "$_suffix" ] && { log_warning "Name can't be empty."; continue; }
[ -d "$DOCKER_DIR/myservice-$_suffix" ] && { log_warning "myservice-$_suffix already exists — pick another name."; continue; }
break
done
INSTANCE_SUFFIX="$_suffix"
DIR="$DOCKER_DIR/myservice-$_suffix"
CONTAINER="myservice-$_suffix"
while ss -tlnH "sport = :${WEB_PORT}" 2>/dev/null | grep -q .; do
WEB_PORT=$((WEB_PORT + 1))
done
log_info "New instance: $DIR (port $WEB_PORT)"
fi
fi
Everything downstream — container_name, hostname, published ports, the
Caddy subdomain default passed to configure_caddy_for_service, log/prompt
text, the generated README's title — reads from $CONTAINER/$WEB_PORT/
$INSTANCE_SUFFIX instead of the literal service name, so it's already
correct for either the first instance or a named one with no further
branching.
Databases: dedicated per instance, not shared. services/wordpress.sh
started as one shared MariaDB container with a separate database per site
(same resource-sharing idea as the shared coturn below), and was
deliberately changed away from that. The reason generalizes: Kopia's
generic backup (services/backup.sh) stops a service's container to get
a consistent snapshot, so a shared database instance backs up — and would
have to be restored — as one unit covering every instance's data at once,
not one instance independently. A dedicated database container per
instance costs more RAM (a full container each instead of one instance
split across several) in exchange for real backup/restore isolation. Data
is typically isolated either way (separate database + user regardless), so
the shared-vs-dedicated choice is about the container/process and its
backup blast radius, not about the data being mixed. Default to dedicated
per instance; only share if a service's own architecture makes that
awkward and the resource savings are worth the backup-coupling tradeoff.
Ports beyond a single one need more than one ss scan, but never
port-by-port for a large range. A service publishing two or three fixed
ports (services/emby.sh, services/mattermost.sh's web+Calls-UDP ports)
just runs the same ss scan once per port. A service publishing a large
range (services/traccar.sh's ~150-port device-protocol range) can't be
scanned port-by-port — instead shift the whole range by a fixed offset per
instance, sized off how many $DOCKER_DIR/<name>* directories already
exist (find "$DOCKER_DIR" -mindepth 1 -maxdepth 1 -name '<name>*' -type d | wc -l — the -mindepth 1 matters, since without it find also matches
$DOCKER_DIR itself if its own basename happens to start with the service
name). Check whether the first instance's range carves out exclusions for
another service's fixed ports (traccar's does, for Asterisk's AMI/SIP
ports) — a large enough offset on additional instances usually clears those
same fixed ports automatically, so the exclusions typically don't need to
be repeated for instance 2+.
Docker labels used by sidecar tooling need per-instance scoping too, not
just container names. services/traccar.sh's autoheal sidecar watches
containers by a Docker label that's visible host-wide, not scoped to a
compose project — two instances both using the literal autoheal label
would each try to restart the other instance's container too. Give the
label itself a per-instance value (autoheal-<name>-<suffix>) and point
that instance's autoheal container at the same value via
AUTOHEAL_CONTAINER_LABEL, the same way container names get suffixed.
Verify port/count logic by actually running it, not just by reading it.
Both real bugs caught while building this pattern into
services/traccar.sh — the find matching $DOCKER_DIR itself, and the
port-scan needing a genuinely free-vs-taken state to prove it increments —
were things code review alone would have missed. Install two instances in
sequence (a fake docker/ss shim standing in for a live daemon is fine)
and confirm the second one's directory, container names, and ports are
actually distinct before trusting the logic.
Port collision avoidance
With 70+ services in this repo, several ship the same default port —
emby and jellyfin both default to 8096, changedetection and frigate
both default to 5000, arm and nextcloud both default to 8080. Nothing
enforced those defaults were actually free on the host: whichever service
started its container second would fail to bind ("port is already
allocated") instead of landing on the next free port. Confirmed live:
installing jellyfin after emby (or vice versa) writes a
docker-compose.yml claiming a port the other service's container already
holds, and it only fails at docker compose up time — not at install time,
and not with any warning from the installer itself.
Every service that publishes a fixed host port must scan for a free one
before writing docker-compose.yml — on every install, not only when
adding an explicit additional instance of itself. Two shared helpers in
lib/common.sh do the work:
port_in_use PORT [PROTO] # true if something's already listening; PROTO defaults to tcp, pass "udp" for UDP-only ports
find_free_port VARNAME START [PROTO] # scans upward from START, writes the free port back into VARNAME
Single-port services just call find_free_port:
local WEB_PORT="8080"
find_free_port WEB_PORT "$WEB_PORT"
...
- "${WEB_PORT}:8080" # host side scanned; container-internal side stays literal
Services with multiple ports that must move together (a web port + an
agent/RTSP/MQTT port, etc.) loop over port_in_use directly instead, the
same pattern services/meshcentral.sh and services/unifi.sh use:
while port_in_use "$WEB_PORT" || port_in_use "$AGENT_PORT"; do
WEB_PORT=$((WEB_PORT + 1))
AGENT_PORT=$((AGENT_PORT + 1))
done
On a normal single-install host this is a silent no-op — the default port is free, so the variable comes back unchanged and nothing about the install looks any different. It only changes behavior when something else already holds the port, which is exactly the case that used to fail at container-startup instead of being handled at install time.
Standalone-mode stub. Every service also carries a standalone
bootstrap fallback (if [[ -f "$_COMMON" ]]; then source it; else <stubs> fi, for sudo bash services/<name>.sh with no sibling files sourced) that
duplicates the helpers it needs. port_in_use/find_free_port get the
same treatment — copy the same two function bodies into that else block,
matching how log_info, prompt_text, configure_caddy_for_service, etc.
are already duplicated there.
Only the host side of a "HOST:CONTAINER" port mapping changes. The
container-internal port is fixed by the application itself and stays a
literal number; only the host-published side becomes ${WEB_PORT} (or
whatever the variable is named). Watch for the same literal number showing
up elsewhere in the file needing the same treatment: configure_caddy_for_service
calls (container-internal side stays literal; a bare-port host-networking
upstream, like services/lyrion.sh's first instance, does need the
variable), generated companion scripts (services/koha.sh's
post-setup.sh, services/rustdesk.sh's relay-host messaging), .env
values baked into client-facing config (services/wg-easy.sh's WG_PORT
env — WireGuard bakes the port into every generated peer config's
Endpoint = line, so it must track the actual published port, not just
the host-side compose mapping), and every echo/README line that prints
http://localhost:<port>.
Quoted (<< 'MD') README heredocs don't interpolate — check before
editing. A write_readme ... << MD (unquoted) heredoc already
interpolates ${WEB_PORT} directly. A quoted << 'MD' heredoc doesn't,
and converting it means escaping every backtick used for inline-code
formatting (\`...\`) — miss one and bash tries to execute it as a
command substitution the next time the heredoc is read, the same class of
bug the coturn.sh backtick incident was (see attic/coturn.sh's
write_readme call). For a README with only one or two backticks,
escaping them is fine. For one with many (services/iopaint.sh's model
reference table), it's safer to leave the heredoc quoted and patch the
port into the written README.md afterward instead:
[ "$WEB_PORT" != "8100" ] && sed -i "s/localhost:8100/localhost:${WEB_PORT}/g" "$IOPAINT_DIR/README.md"
network_mode: host services can only scan what the app itself lets you
override. Host networking has no port remapping — whatever the app
binds to on its fixed internal port is what's exposed, so find_free_port
only helps for ports the app takes as configurable env vars.
services/lyrion.sh's HTTP_PORT env is genuinely configurable (LMS
honors it as the actual bind port under host networking too — see its
_HTTP_PORT_INTERNAL handling, which must track the scanned port rather
than assuming bridge-mode's fixed 9000), but its CLI (9090) and player
(3483) ports are hardcoded in the image with no override — a collision
there can only be warned about with port_in_use, not silently fixed.
services/homeassistant.sh is the same shape: bridge mode (the default)
scans freely; host mode (opt-in, for LAN device discovery) can only warn.
Caddy is the one deliberate exception — never auto-scanned.
services/caddy.sh keeps 80/443 fixed and only warns via port_in_use if
they're already taken. Every other service either points HTTPS clients at
Caddy implicitly (browsers assume 443) or gets routed through it by
domain; silently moving Caddy itself to a random port would leave nothing
listening at the address any client actually tries, which is strictly
worse than the collision it would be "fixing." If 80/443 are already
bound, that's a real conflict (another web server on the host) the user
needs to resolve directly.
Chaining into another service from within your own
A service can call another service's install_<name>() directly as a
convenience step at the end of its own flow, instead of making the user
remember to separately run sudo ./setup.sh <other-name> afterward.
services/asterisk.sh does this for
services/security-dashboard.sh and services/pstn-trunk.sh — after
Asterisk itself is installed/updated, it asks once whether to also set up
the dashboard and/or a PSTN trunk (or, if either is already installed,
silently re-invokes it so it gets refreshed as part of the same run — its
own prompt_reinstall_mode gate decides update vs. skip, so this never
re-asks the target service's detailed prompts unless the user is actually
setting it up fresh).
The target service keeps its own register_service call — it stays
independently selectable/invocable exactly as before (sudo ./setup.sh pstn-trunk still works standalone). Chaining is purely additive, not a
replacement for the target's own entry point, so nothing breaks for anyone
already relying on running it directly.
Guard every cross-file call with declare -F, since a service can also run
completely standalone (sudo bash asterisk.sh, no setup.sh, no sibling
services/*.sh files sourced at all):
if declare -F install_security-dashboard >/dev/null 2>&1; then
install_security-dashboard
fi
Only chain in one direction, and only when the relationship is genuinely
one-way (the target is meaningless without the caller already installed —
pstn-trunk.sh itself says so in its own error message when Asterisk isn't
present). Don't have both sides call each other.
.env files and secrets
Generate passwords with generate_password (never hardcode them).
Write secrets to .env files in the service directory, owned by
ACTUAL_USER, permissions 600. Document every variable with a comment
in the .env heredoc so the user knows what to change later.
Caddy network wiring
Services that need to reach Caddy (or each other) over Docker networking
should join the $SITE_CADDY_NET network. Add to docker-compose.yml:
networks:
caddy_net:
external: true
name: ${CADDY_NET:-caddy_net}
And read the network name from .env using CADDY_NET=$SITE_CADDY_NET.
external: true means this service expects the network to already exist —
it doesn't create it. require_docker creates it for you (via
ensure_caddy_network in lib/common.sh) the first time any service calls
it, so as long as your install_<name>() calls require_docker before
docker compose up (it always should), the network is guaranteed to exist
regardless of whether Caddy itself has been installed yet.
network_mode: host services (e.g. asterisk) don't join
caddy_net at all — Caddy reaching them (or anything else on the host
network) needs host.docker.internal:PORT in the Caddyfile, not
localhost:PORT or a container name. Caddy's own compose file
(services/caddy.sh) sets extra_hosts: host.docker.internal:host-gateway
so that hostname resolves; configure_caddy_for_service's bare-port upstream
case already does this for you — don't hand-roll localhost:PORT in a
Caddy site block.
coturn (TURN/STUN) relay — dedicated per service, not shared
Any service that needs a TURN server for WebRTC/SIP NAT traversal runs its
own dedicated coturn container. There is no shared coturn service to
install or point at — services/coturn.sh was tried and retired; it's
parked at attic/coturn.sh (outside services/*.sh's glob, so it never
registers or appears in the menu — see attic/README.md). Sharing one
instance saved a container per consumer (~40MB) but was a single point of
failure every consumer depended on, and needing a dedicated per-consumer
long-term-credential user added real setup complexity for a small RAM win.
Don't reintroduce it — give every new WebRTC/SIP-capable service its own
coturn, following the pattern below.
The collision this pattern has to avoid: asterisk and mattermost
each embed a dedicated coturn container (network_mode: host, each with
its own relay port range) — confirmed live, two independent coturns'
default ranges used to overlap by ~100 UDP ports, so running both on one
box meant a coin-flip over which service's active call lost its media
relay. Static default ranges alone don't solve this; something has to pick
non-overlapping ranges per box.
Use find_free_coturn_range (lib/common.sh) to size the range, not a
hardcoded default:
local MY_COTURN_MIN_PORT=49152 MY_COTURN_MAX_PORT=49252
find_free_coturn_range MY_COTURN_MIN_PORT MY_COTURN_MAX_PORT 100 49152
[[ "$MY_COTURN_MIN_PORT" != 49152 ]] && \
log_info "Dedicated coturn relay range shifted to ${MY_COTURN_MIN_PORT}-${MY_COTURN_MAX_PORT} to stay clear of another coturn already on this box."
Unlike a single fixed host port (find_free_port's job — coturn's relay
range isn't a statically bound listening socket you can detect with a live
ss/socket scan), find_free_coturn_range scans every $DOCKER_DIR/*/.env
for a TURN_MAX_PORT= line and starts the new range 50 ports past the
highest one found — so it works across every coturn-owning service on the
box (Asterisk, each Mattermost instance, yours), regardless of install
order. Persist the chosen range as TURN_MIN_PORT=/TURN_MAX_PORT= in your
own .env so later installs' scans see it, and on an update rerun read
those same keys back from the existing .env instead of re-scanning — a
live coturn container must never silently move to a different port range
(breaks in-flight/repeat sessions on whatever client already has the old
range's ports allowed through its own firewall/NAT). See
services/asterisk.sh's and services/mattermost.sh's EMBEDDED_COTURN_MIN_PORT/
MM_COTURN_MIN_PORT handling for the reference pattern, including the
MODE != "update" gate that scans only on a fresh install.
Auth mode — long-term credentials (--lt-cred-mech) or HMAC
(--use-auth-secret), your choice per instance: coturn doesn't support
running both on one instance at once, but since each service now owns its
instance outright, this is a free per-service choice — no shared-instance
constraint forcing one mode across every consumer. services/asterisk.sh
uses --lt-cred-mech (fixed username/password, simplest to bake into a SIP
device's config); services/mattermost.sh uses --use-auth-secret (HMAC),
matching the Calls plugin's own "TURN Static Auth Secret" field. Check the
consuming app's own TURN settings UI for which fields it actually exposes
before picking.
Legacy installs still on the old shared coturn: an update rerun on an
install that predates this repo's dedicated-coturn-only model (no coturn:
block in its docker-compose.yml) must not try to silently migrate or
"heal" it — there's no shared coturn service left in this repo to heal it
against. Leave it running exactly as-is (an update never touches .env
anyway) and point at a full/fresh reinstall as the migration path, which
generates a new dedicated coturn container with fresh credentials. See the
_HAD_EMBEDDED_COTURN handling in services/asterisk.sh and
services/mattermost.sh for the reference pattern — detect via grep -q '^ coturn:' docker-compose.yml before regenerating it, same as any other
non-destructive update path in this file.