- service-map render node-exporter-unit --host H: the MIRROR pair comment
('keep this file in step') becomes a template. Per-host title, LAN
address from the hosts map, port from the node-<host> scrape job. Both
tracked quadlets are now rendered artifacts; check_drift gates them.
- dankclaw/Caddyfile: the nine verbatim Anubis pre-auth chains
(reverse_proxy anubis:8923 + X-Real-Ip) collapse into the (guarded)
named snippet; guarded vhosts are 'import guarded'. Validated with real
caddy 2.11.7 (import resolution clean; the stock image stops at the
porkbun module and the git basicauth placeholder, as designed).
- vhost-scoped guard greps (authentik, homepage_caddy, marreta_quadlet)
now pin 'import guarded' in the vhost block.
- Service Inventory + Authentik docs carry the new laws.
29 KiB
type, description, tags, timestamp
| type | description | tags | timestamp | ||
|---|---|---|---|---|---|
| reference | Authentik is the single sign-on (SSO) and OpenID Connect (OIDC) provider for the |
|
2026-09-16 |
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.3(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.3 |
authentik-worker |
Background tasks (event processing, provider sync, flows) | ghcr.io/goauthentik/server:2026.8.3 |
- 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.
Self-serve user-settings flow (password change)
Users change their own account details through the default-user-settings-flow
(https://auth.franksdank.cloud/if/flow/default-user-settings-flow/) — the
target of the homepage footer's "account" link. It is a stage_configuration
designated flow, so it renders every bound ConfigurableStage for the logged-in
user.
Stages, in execution order (the FlowStageBinding.order field, not the M2M
insertion order):
| order | stage | type | what it does |
|---|---|---|---|
| 20 | default-user-settings |
PromptStage |
name / email / locale + password |
| 100 | default-user-settings-write |
UserWriteStage |
writes the prompt data to the user |
Password change is a prompt field, not a stage. The UserWriteStage
writes whatever the preceding PromptStage collected: for the key password
it calls user.set_password(value) (stages/user_write/stage.py). So the fix
is a password field on the default-user-settings prompt
(field_key=password, type=password, required=False, order 204) — NOT a
PasswordStage. A PasswordStage is an authentication stage: its
validate_password runs authenticate() against the backends to check the
current password; it never sets a new one. (A PasswordStage was added on
2026-09-25 and removed the same day for exactly this reason.)
Two ORM gotchas: Flow.stages.add() fails on this through model
(Can't bulk create a multi-table inherited model — FlowStageBinding is
MTI), so create the FlowStageBinding row directly with an explicit order;
and a prompt field is a Prompt row linked through PromptStageField
(prompt_stage + prompt; the order comes from Prompt.order).
A blank password must not set an empty password. set_password("") would
make the account log in with no password, so a validation policy
(default-user-settings-no-empty-password, bound to the prompt via
PromptStageValidationPolicy) denies the form when prompt_data["password"]
is "" and allows it when set. Verified with the PolicyEvaluator
(empty → deny, set → allow, absent → allow) and end-to-end by driving
UserWriteStageView.update_user on a throwaway user (new password valid, old
invalid).
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).
Guard-chain flip recipe (adding a guarded vhost)
Move a public vhost from reverse_proxy <app>:<port> onto the chain:
client → TLS edge → Anubis :8923 → outpost decision → internal
http://:8017 listener → Host-matched handler → real upstream.
Order (never reorder):
- Provider first. Create the outpost ProxyProvider before touching
routes, or authed traffic hits the outpost error page instead of the app.
Fleet convention for
external_hostis the exact browser-facing origin (the inventory table carries both bare origins and trailing slashes; routing matches on hostname, verified 2026-09-02).internal_host = http://caddy:8017, all three flows set, and an Application attached — the two provisioning rules above. - Route flip in ONE commit/deploy: the vhost body becomes
import guarded— the named snippet at the top of the Caddyfile is the whole pre-auth chain (reverse_proxy anubis:8923+header_up X-Real-Ip {remote_host}; rank 6 retired the nine verbatim copies), and inside thehttp://:8017listener add@<svc> host <vhost>+handle @<svc> { reverse_proxy <upstream> }. Anubis guard set, Caddy TLS edge,:8017routing table, and provider list change together.tests/caddyfile.batspins that the chain exists exactly once as the(guarded)snippet and every guarded vhost imports it. - Deploy via the repo deploy flow (commit first; deploy pushes main itself).
Verification (all three):
- Unauthenticated probe from a client:
curl -s https://<vhost>/→ HTTP 200 challenge page; grep the body forchallenge|not a bot|anubis(~8 markers on a live challenge). A 502 here instead of the challenge = chain mis-wired. - Post-auth routing proof: grep the LIVE config inside the container —
podman exec caddy sh -c "grep -n -A3 <handler> /etc/caddy/Caddyfile"— after the deploy prints its reload line; confirm the reload actually ran, don't trust exit 0. - Outpost reachability:
podman exec authentik-server getent hosts caddyresolves the compose DNS alias (10.89.1.x) thatinternal_hostdials.
Pitfalls: probing http://localhost:8017/ from the host returns HTTP
000 (connection refused) — the post-auth listener lives in the caddy
container's netns; probe inside it or grep the config. Non-browser clients
cannot solve the JS challenge — before flipping, audit service-to-service
traffic: ingestion/push paths must target internal addresses (loki:3100),
never a public hostname of a guarded service. Exempt-by-construction
categories stay unguarded: the IdP itself (auth.), git smart-HTTP
endpoints, services with their own complete auth — document the exemption
in the vhost comment.
Driving the ORM (ak shell)
All authentik object work happens through the server container's ORM shell — no repo change, no deploy (these providers are NOT repo-managed; capture new provider+application pairs as a blueprint or setup script to avoid drift).
ssh dankclaw 'podman exec -i authentik-server ak shell' <<'EOF' | sed -n '/229 objects/,$p'
from authentik.core.models import User, Group
...
EOF
- The heredoc IS the program:
ak shellreads all of stdin and execs it (heredoc-through-ssh quoting breaks; the raw-stdin pattern is the proven one). - Bootstrap prints JSON log lines, then
### authentik shell (2026.8.3)and229 objects imported automatically…— pipe throughsed -n '/229 objects/,$p'to drop the noise. - 229 models are pre-imported. Import paths (each wrong guess costs a failed
exec):
User,Group,Application,ApplicationEntitlement,PropertyMapping→authentik.core.models;ProxyProvider→authentik.providers.proxy.models(NOTauthentik.outposts.models);LDAPProvider→authentik.providers.ldap.models;authentik.core.models.applicationdoes NOT exist as an import path; there is noauthentik.policies.groupmodule in this build. - For live DB truth prefer the postgres container
(
podman exec authentik-postgres psql -U authentik -d authentik -At -c "select * from …") — query withselect *and stop guessing column names: 2026.x renamed columns (Provider.objects.filter(type=…)raises FieldError —typemoved to subclass tables; the outpostenabledcolumn may not exist under that name; provider pk isoauth2provider_ptr_id).
User provisioning playbook (user-level account)
There is no User.create_user classmethod, and is_superuser is NOT a
constructor kwarg (it is a property derived from group membership) — passing
it raises TypeError: User() got unexpected keyword arguments:
from authentik.core.models import User, Group
u = User(username="ridley", email="ridley@franksdank.cloud", name="Ridley", is_active=True)
u.set_password("...") # password arrives on stdin only, never argv
u.save()
for g in ("Non-Admin Users", "jellyfin_users", "openwebui_users"):
u.groups.add(Group.objects.get(name=g))
path defaults to users (regular). The access map (verified live
2026-10-04): there are NO per-app policy bindings on the proxy providers
(all use the implicit-consent "Authorize Application" flow), so guarded-vhost
access is decided entirely by GROUP MEMBERSHIP consumed downstream —
homepage-admins→ Caddy RBAC for homepage/admin(X-Authentik-Groupsregex gate) and the homepage admin panel; regular users get onlycategory: userpanels fromservices.json.openwebui_users/openwebui_admins→ ApplicationEntitlements "Open WebUI Users"/"Open WebUI Admins" (bound by group), consumed by the "Open WebUI roles" property mapping →rolesclaim. Verify live withu.app_entitlements(app).jellyfin_users/jellyfin_admins→ Jellyfin via the LDAP outpost (filter(memberOf=cn=jellyfin_users,ou=groups,dc=franksdank,dc=cloud)).Non-Admin Users= the plain-user marker group (precedents: qa-e2e, and every human account).- The human-user template (ridley, kassie, alex, artem, kirby, ry, nic,
debo, freya — kirby/ry/nic/debo/freya (pk 17–21) created 2026-10-09, kassie
and alex created 2026-10-08, artem (pk 16) created 2026-10-07):
username+<user>@franksdank.cloud+ display name +is_active=True+ the password set viaset_password, membership inNon-Admin Users+jellyfin_users+openwebui_users.alexwas recreated from scratch on 2026-10-08 into this shape (the old row had an empty email and noopenwebui_users; audit first — the row carried no MFA device, token, entitlement, or proxy session, and events survive a user delete becauseEvent.useris a denormalized JSON field, not a FK). - EXCLUDE from a user-level account:
homepage-admins,jellyfin_admins,openwebui_admins,authentik Admins,authentik Read-only. - Other user-category vhosts (SearXNG, Gitea, Calibre-Web, RomM, Copyparty, Plex, Seerr, Marreta) need no per-user group: any authenticated user passes the outpost.
Verify login behaviorally — authenticate() is the exact PasswordStage code
path:
from django.contrib.auth import authenticate
assert authenticate(request=None, username="ridley", password="...") # -> User
assert authenticate(request=None, username="ridley", password="nope") is None
Look flows up by slug, not name:
Flow.objects.get(slug="default-authentication-flow") (its .name is
"Welcome to authentik!" — .get(name=…) fails). Stage order:
Identification(10) → Password(20) → AuthenticatorValidate(30; no-op with no
enrolled device) → UserLogin(100). With no MFA enrolled, nothing else blocks.
More field gotchas (each cost a failed exec): ApplicationEntitlement.application
can be null (guard with getattr); LDAPProvider has
search_mode/bind_mode/base_dn, lacks search_base/of_groups/
organization; OAuth2Provider has no .scopes; Outpost.config is an
OutpostConfig object, not a dict; Policy has no uniform .policy_cls
(use type(p).__name__); User has no .status.
Never rotate AUTHENTIK_SECRET_KEY to fix claim issues — it invalidates
every outpost session fleet-wide; purge the stale proxysession rows
instead (section above).
Probing the outpost from inside the netns
Rootless podman gives each container its own netns; host ss -ltn cannot
see listeners inside other containers' namespaces — a missing listener on
the host is an observation artifact, not a fault. Distroless images have no
shell (podman exec authentik-server sh fails), so probe by joining the
namespace:
podman run --rm --network container:<container> --entrypoint /bin/busybox \
docker.io/library/busybox:latest \
wget -q -T 4 -t 1 -O /dev/null -S "<url>" 2>&1 | head -8
- Quote URLs over ssh: the remote shell is zsh and bare
?in query strings triggers globbing (no matches found). - Boot transience: immediately after a restart the same probe can return 503 (configure task pending); poll until the answer changes before concluding.
podman inspect --format "{{.Config.NetMode}}"errors on this podman version — the field is.HostConfig.NetworkMode.
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