Retiring dankclaw/compose.yaml and adding alert rules invalidated claims across
the repo, including in the rule file agents read first. Clean cutover includes
the prose.
**Tests that had gone hollow.** Four assertions grepped dankclaw/compose.yaml
for a service name. With the file gone they passed by *absence* — a gate that
can no longer fail. Each now asserts the real invariant ("no dankclaw compose
file competes with the quadlet units") and keeps a live check for a re-added
entry, so it still fails when one appears: verified by recreating a compose file
with a comfyui service and watching three tests go red, then removing it.
**AGENTS.md, which was wrong about ComfyUI.** It said ComfyUI runs CPU-only
("no ROCm wheels for gfx1151, CUDA images wrong"). That was true in the
compose era and is false now: the restored unit runs with /dev/kfd + /dev/dri and
must NOT pass --cpu. An agent trusting the rule file would have re-broken it.
Also corrected: the observability paragraph ("RUNNING BUT UNCONFIGURED, no
datasource, no rules" — all three have been configured since 2026-09-16), the
Active runbook row that still named halogen for frank-large, halogen's absence
from the Retired table, the benchmark policy naming halogen's harness, and the
seam list.
**Unit and page claims.** prometheus.container said "no alerting rules anywhere
in this stack"; node-exporter.container said Dankserver runs from its compose
stack; Prometheus.md documented three jobs as working (they scrape HTML: they
can never report up) and said no dashboard existed; index.md and the three
authentik service pages pointed at the retired file; Authentik.md's upgrade
instruction told you to bump an image tag in it.
One bad edit of my own on the way (a stale line number clobbered a See Also
entry in Prometheus.md) — caught by reading back, fixed, and the vault link
checker now proves the cross-references hold.
18 KiB
Authentik — the homelab's SSO / OIDC provider
Authentik is the single sign-on (SSO) and OpenID Connect (OIDC) provider for the homelab. It protects the Caddy vhosts that do not carry their own application-level authentication with its embedded proxy outpost. Apps that have their own login (Grafana, Gitea, OpenWebUI, Jellyfin, the *arr stack, Pi-hole) are not wrapped — they handle their own session.
- Version:
2026.8.0(pinned indankclaw/authentik/quadlet/*.container) - Host: dankclaw (
192.168.0.143) - Name:
auth.franksdank.cloud - Stack: 3 compose services —
authentik-postgres,authentik-server,authentik-worker(see Pi-hole for the LAN-DNS pattern) - LDAP outpost: a 4th, quadlet-deployed unit (
authentik-outpost-ldap) serves LDAP for Jellyfin — see the section below.
Why Authentik
The comparison that chose it (against Authelia, Keycloak, Zitadel, Ory) is in
.docaudit/research/auth.md. In short: full OIDC with single-logout, a web GUI
for all configuration, an outpost that terminates auth for reverse-proxied
apps, and a footprint that fits on a 3-user homelab (~1–1.5 GB idle). Redis
was removed in 2025.10, so the stack is 3 services, not 4. (The integration
mechanism changed in 2026.x — see Architecture; the research file predates
the pin.)
Architecture
graph LR
U[Browser] -->|1. GET /search?q=x| A[Anubis :8923<br/>bot challenge]
A -->|2. verified, dials internal listener| C[Caddy :8016<br/>auth decision]
C -->|3. whole request, Host preserved| S[authentik-server :9000<br/>embedded outpost]
S -->|4. no session: login flow served in place| U
S -->|5. session valid: proxies on to internal_host| P[Caddy :8017<br/>post-auth routing]
P -->|6. app response back up the chain| U
S --> PG[(postgres)]
Two mechanisms were disproved live (2026-08-29/30) and one is the actual contract:
- The old
/outpost.goauthentik.io/auth/caddyforward-auth endpoint does not exist in the 2026.8 URL tree — every unauthenticated subrequest got authentik's own 404 page. X-authentik-meta-outpost/-provider/-appheaders are IGNORED for routing in this build: a request carrying Caddy's full meta-header set still 404s, while the same request with only a matchingHostanswers. The embedded outpost routes solely on the originalHost, matched against each attached proxy provider'sexternal_host.- Consequence: every guarded host needs its own proxy provider (exact
external_host), and:8016is a single uniformreverse_proxytoauthentik-server:9000that preservesHost. Unauthenticated → login flow served in place; authenticated → forwarded to the provider'sinternal_host(http://caddy:8017), which routes straight to the app. The chain terminates there — nothing dials back into Authentik from:8017.
The three compose services
| Service | Role | Image |
|---|---|---|
authentik-postgres |
PostgreSQL 16 (users, flows, sessions, events) | docker.io/library/postgres:16-alpine |
authentik-server |
Core + admin API + the embedded Caddy outpost (port 9000) | ghcr.io/goauthentik/server:2026.8.0 |
authentik-worker |
Background tasks (event processing, provider sync, flows) | ghcr.io/goauthentik/server:2026.8.0 |
- Postgres is the only stateful store. Sessions, caching, and WebSockets moved to PostgreSQL in 2025.10 (Redis removed).
- Server and worker run the same image; the
commandselects the role (server/worker). - The worker runs as
0:0(rootless: container root maps to the host user that owns the data dir). - Data persists under
/home/dank/Containers/authentik/{data,postgres}(bind mounts, repo policy — no named volumes; moved from/home/dank/authentik/in the 2026-09-01 quadlet standardization).
Caddy integration
Guarded vhosts reach Caddy's internal listener :8016 (post-Anubis), which
proxies every request whole to the Authentik server with Host
preserved — there is no forward_auth and no per-vhost block:
http://:8016 {
reverse_proxy authentik-server:9000 {
header_up X-Forwarded-Proto https
}
}
The embedded outpost inside the server process decides by Host:
no session → the provider's authentication_flow is served in place;
session valid → the request is forwarded to the provider's internal_host
(http://caddy:8017), where per-vhost handle blocks reach the apps.
Lockstep rule: the Anubis guard set, the Caddy TLS edge, the :8017
routing table, and the Authentik proxy-provider list must be the same set of
hosts. A guarded host with no provider 404s at login time; a provider with no
DNS record breaks off-LAN clients.
Live provider inventory (verified 2026-09-02), all attached to outpost
7e39655b-… ("authentik Embedded Outpost"), all with
internal_host = http://caddy:8017, all three flows set, and each linked to
an Application (see the provisioning rules below):
| Provider pk | name | external_host | app slug |
|---|---|---|---|
| 1 | embedded-outpost | https://www.franksdank.cloud |
caddy |
| 2 | search-proxy | https://search.franksdank.cloud |
search |
| 4 | prometheus-proxy | https://prometheus.franksdank.cloud |
prometheus |
| 5 | romm-proxy | https://romm.franksdank.cloud |
romm |
| 6 | files-proxy | https://files.franksdank.cloud |
files |
| 7 | loki-proxy | https://loki.franksdank.cloud/ |
loki |
| 8 | grafana-proxy | https://grafana.franksdank.cloud/ |
grafana-guard |
| 10 | seerr-proxy | https://seerr.franksdank.cloud/ |
seerr-guard |
Provider 3 (hermes-proxy) still exists but is dormant — its vhost was
decommissioned from Caddy on 2026-08-31. Provider 9 (grafana) and 12
(Seerr OIDC) are OAuth2 relying-party providers, not proxy providers; the
outpost does not deserialize them. Trailing slashes on external_host (the
runbook-recipe style) and bare origins both intercept correctly — the outpost
matches on hostname.
Two provisioning rules, each verified live by an outage:
- All three flows are required. A ProxyProvider created via raw API/ORM
with any of
authentication_flow/authorization_flow/invalidation_flownull wedges the embedded outpost's config fetch: the Rust side rejects the whole config JSON (serde:invalid type: null, expected a formatted UUID string), logsFailed to fetch outpost from API, retrying in 3 secondsforever, and everything behind the outpost dies at the next outpost reload — including hosts whose providers were fine (verified 2026-09-02 when three providers provisioned without any flows took down every guarded vhost after a server restart). Backfill all three flows (copy from pk 1:default-authentication-flow,default-provider-authorization-implicit-consent(the implicit-consent authorization flow,default-invalidation-flow) and the retry loop recovers on its next 3-second cycle. The silent killer: while the outpost process keeps its last good in-memory config, everything works — the outage only surfaces on restart/reload. - Every proxy provider needs an attached Application. The outpost
resolves the post-login redirect through the provider's application; a
provider with no app completes the login (authentik session established)
but bounces to authentik's own user UI (
/if/user/#/library) instead of the app. No error is logged anywhere — the SSO "works" and the app never appears. Create the Application withprovider_idset (the UI does this automatically; raw API/ORM drives must do it explicitly).
OAuth2 relying-party quirks (this fork, verified live 2026-08-31)
The Grafana RP provider (grafana, pk 9, confidential client) was created and
verified end-to-end via ak shell ORM drives. This fork's OAuth2 provider
implementation diverges from upstream in ways that bit us; all verified live:
- Application row required.
OAuth2Provider.objects.create()does not create the single-table-inheritanceApplicationrow; without it the flow executor can never complete an authorize request. Create both rows. grant_typesdefaults to empty. Until the provider'sgrant_typescontains"authorization_code"(enum value with underscore), the token endpoint rejects every exchange withinvalid_request.- Token endpoint is form-encoded + Basic-auth only. A JSON body makes
parse_token_requestsee nogrant_type→unsupported_grant_type; POST-body client secrets are not accepted — authenticate the confidential client withAuthorization: Basic base64(client_id:client_secret). - Provider resolution is by
client_idquery param on the global/application/o/authorize/route; per-slug authorize paths do not exist in this build. Token/userinfo live at/application/o/token/and/application/o/userinfo/?api=1. - Property-mapping evaluator is broken → userinfo returns
subonly.PropertyMappingEvaluator.evaluate(*args, **kwargs)forwards kwargs toBaseEvaluator.evaluate(expression_source), so the first context kwarg becomes the expression source instead of the context; every call raises a swallowedPropertyMappingExpressionExceptionand the claim is skipped. Scope-grant filtering has the same class of bug:ScopeMappinghas no provider FK (linkage lives in theProviderPropertyMappingthrough model), sofilter(provider=...)always yields an empty queryset and tokens asked to carry scopes get zero. Net effect: userinfo/introspection answers with{sub}only — email/groups/entitlements claims cannot be delivered by this build until the evaluator is patched or upgraded. Clients consuming this provider must key identity offsub. - Scope mappings for this provider exist (
grafana-email-scope,grafana-profile-scope, Python-syntax expressions) and will start emitting claims the moment the evaluator bug is fixed — no config change needed then.
LDAP outpost (Jellyfin auth, live 2026-09-01)
A fourth unit, dankclaw/authentik/quadlet/authentik-outpost-ldap.container
→ authentik-outpost-ldap.service, serves LDAP for Jellyfin's LDAP-Auth
plugin. The image is pinned by its amd64 single-platform digest
(ghcr.io/goauthentik/ldap@sha256:fa33bc8f522113830e1662f4c034352f9eb08e0a7d3da2ddadad9a4b33b4cbf4):
podman 5.7.0 refuses the
ghcr.io/goauthentik/ldap:2026.8.0 tag because the tag resolves to a
nested manifest list (index → per-platform manifest lists) —
unexpectedly received a manifest list instead of a manifest for a single image. Resolve once with podman manifest inspect <image> | jq -r '.manifests[] | select(.platform.architecture=="amd64") | .digest' and pin
that digest.
- Ports 389 (ldap) and 636 (ldaps) publish on the LAN; Jellyfin on
Dankserver binds
192.168.0.143:389. The outpost token travels inAUTHENTIK_LDAP_OUTPOST_TOKENfrom the host.env(git-ignored).
Authentik-side objects (provisioned via ak shell ORM)
| Object | Value |
|---|---|
| LDAP provider | Jellyfin LDAP (pk 11), base DN dc=franksdank,dc=cloud |
| Bind DN | cn=svc-jellyfin-ldap,ou=users,dc=franksdank,dc=cloud |
| Service account | svc-jellyfin-ldap — passwordless/unusable; authenticates by token |
| Bind token | intent verification, non-expiring — its key is the LDAP bind password |
| RBAC role | svc-jellyfin-ldap-search carrying object-level permission authentik_providers_ldap.search_full_directory on the provider |
| Groups | jellyfin_users, jellyfin_admins |
| Application | jellyfin (slug jellyfin) |
Hard-won rules, each verified live:
- Token intent is load-bearing. A bind token with
intent=app_passwordis rejected by the flow's password stage withInvalid password;intent=verificationpasses. The outpost's direct binder executes the provider's authorization flow, and that password stage only accepts verification-intent tokens as static passwords. - Search needs
search_full_directory. Without the RBAC grant the outpost returns only the bind user itself — a(memberOf=…)filter then matches zero entries and the plugin logsFound no users matching X. The permission is object-level on the provider; grant it via an RBAC role and retest. ak shellimport path for the provider model in 2026.8:from authentik.providers.ldap.models import LDAPProvider.
Configuration (secrets)
Two secrets live in the host .env (git-ignored; placeholders in
.env.example):
AUTHENTIK_PG_PASS— the PostgreSQL password. Generate:openssl rand -base64 32.AUTHENTIK_SECRET_KEY— the server's signing key. Generate:openssl rand -base64 48.
First-time setup
./scripts/deploy.sh dankclaw— pulls, starts the 3 services (postgres health-gated), reloads Caddy.- Open
https://auth.franksdank.cloud— the initial setup wizard runs. It creates the superuser (email + password) and generates the DB schema. - For every guarded vhost, in the admin interface: Directory →
Applications → add an application + a Proxy provider whose
external_hostis exactly the browser-facing origin (https://<host>),internal_host = http://caddy:8017, and all three flows set (see the provisioning pitfall above). Attach the provider to the embedded outpost. - For apps with native OIDC (Grafana, Gitea, OpenWebUI): add an OAuth2/OpenID
Connect provider, create the app with the correct redirect URI, and paste
the
client_id/client_secretinto the app's SSO settings.
Operations
- Health:
https://auth.franksdank.cloud/-/health/(the server healthcheck probes this). - Logs:
podman logs authentik-server/podman logs authentik-worker. - Backups:
pg_dumptheauthentik-postgresdatabase (the single source of truth). The/home/dank/Containers/authentik/datadir holds media + static files. - Upgrades: bump the image tag in the three units under
dankclaw/authentik/quadlet/, run./scripts/deploy.sh dankclaw. Authentik migrates the schema on startup.
Outpost claim cache (proxysession) — 403 after a group add
The embedded outpost does not re-evaluate group membership per request. Each
completed authorize writes one row to authentik_providers_proxy_proxysession
whose session_data JSON holds the claims (groups included) that get served
for that browser session until the row expires (observed TTLs 1–24 h per
row). A session authorized before a user was added to a group keeps
serving the old group list — Caddy-side RBAC header gates then 403 despite
correct membership, and no authentik event is generated (the 403 happens at
Caddy; authentik never sees the request).
Diagnosis + fix (verified live 2026-09-01):
-- who holds which cached groups (run in the authentik-postgres container)
SELECT session_data->'claims'->>'preferred_username' AS user,
session_data->'claims'->>'groups' AS cached_groups,
expires
FROM authentik_providers_proxy_proxysession ORDER BY expires;
-- invalidate one user's cache; their authentik login session is untouched,
-- so the next visit re-authorizes silently and mints fresh claims
DELETE FROM authentik_providers_proxy_proxysession
WHERE session_data->'claims'->>'preferred_username' = 'akadmin';
The silent re-auth path was proven end-to-end: after deleting
qa-e2e-admin's rows, the e2e admin spec ran against the stale storage
state and passed with no login prompt (2 tests, 2.9 s).
Failure modes
| Symptom | Cause | Fix |
|---|---|---|
| Every vhost 502s at the outpost | authentik-server down |
podman logs authentik-server; check postgres health |
| Login loops (302 → login → 302) | trusted_proxies / X-Forwarded-* mismatch |
Confirm Caddy sets X-Forwarded-For; the outpost trusts private_ranges |
| "Invalid signature" on the outpost | AUTHENTIK_SECRET_KEY differs between server and worker |
Both services read the same .env var; re-deploy |
| Postgres "password authentication failed" | AUTHENTIK_PG_PASS changed after first init |
Reset via pg_ctl or re-init the data dir |
| "Request has been denied: Invalid next URL" at sign-in | Flow entered with an absolute cross-host next= (old redirect design) |
Do not hand-craft flow URLs; enter guarded hosts normally — the outpost carries state in its own cookie |
/outpost.goauthentik.io/* 404s on every host |
Any provider row with a null flow (authentication/authorization/invalidation) wedges the outpost config fetch — all guarded vhosts die at the next outpost reload |
Backfill all three flows on every provider; watch podman logs authentik-server for Failed to fetch outpost from API |
Login completes but lands on authentik's user UI (/if/user/…) instead of the app |
Proxy provider has no attached Application (outpost cannot resolve the post-login redirect) | Create the Application with provider_id set — no error is logged, so test with a fresh browser context |
| Off-LAN clients get "hostname could not be found" | Guarded host missing from the public DNS zone | Every host in the lockstep set needs a public A record (Porkbun) as well as the Pi-hole LAN answer |
| Correct membership but one user still 403s at a header gate | Outpost claim cache: a proxysession row minted before the membership change serves old claims until expiry |
Delete that user's proxysession rows (section above) — next visit re-authorizes silently |
Cross-references
- Caddy — the reverse proxy carrying the
:8016/:8017auth-chain listeners - Pi-hole — the LAN DNS that resolves
auth.franksdank.cloud - Jellyfin — consumes the LDAP outpost via its LDAP-Auth plugin
- Network Architecture — the flat naming scheme
- Research:
.docaudit/research/auth.md— the full provider comparison