Part 12 of the series: Building a Self-Hosted AI Development Platform
5 min read

Building a Self-Hosted AI Development Platform — Part 12: One Login with Authentik

Part 12 of the Forge series: a self-hosted identity provider for the platform — OIDC for the Immich apps, a worker with no Docker socket, break-glass accounts, and a restore proof before trust

Part 12: One Login with Authentik

Part 8 put Cloudflare Access in front of every public hostname, and for months that was enough. Part 11 ended with the case that broke it: the Immich iPhone app cannot complete a browser login flow. This post covers the platform’s answer — Authentik, a self-hosted identity provider — and the unusually careful rollout it received. An identity service sits in front of everything else. It earns trust last.

Why an Identity Provider at All

Cloudflare Access answers one question: is this HTTP request from an allowed person? That gate works for browsers, and it protects services that have no real login of their own. It cannot do three things the platform now needs.

It cannot serve native applications. The Immich mobile app, and every client like it, speaks OAuth and OpenID Connect, not interactive browser challenges. It cannot give applications an identity. Behind Access, Immich still does not know who signed in — accounts, groups, and permissions need a real identity source. And it cannot be the only layer. An access model with one owner is an access model with one point of failure.

Authentik provides what Access cannot: a standards-based OIDC provider that applications integrate with natively, with users, groups, sessions, and multi-factor authentication. The two layers now divide the work. Access remains the outer gate for browser-only services. Authentik is the application login. Its scope is deliberately narrow: browser identity only. SSH keys, Git tokens, API tokens, and each service’s local recovery account stay separate.

The Stack, and the Socket That Is Not There

Authentik runs as three containers: a server, a worker, and a dedicated PostgreSQL 18 database. State lives under /opt/platform/data/authentik/ on the SSD tier, in the pattern every stateful service in this series follows.

One departure from the official Compose file matters most. The official file mounts the Docker socket into the worker, so Authentik can manage its own outpost containers. This deployment removes that mount. A Docker socket is root-equivalent control of the host — a container that holds it can start, stop, and reconfigure every other container. An identity provider is also the most attractive target on the platform. Combining the two gives an attacker who compromises the login system control of the machine as a bonus.

The embedded outpost covers every current integration without the socket. The rule from Part 6 applies again, now to permissions instead of services: grant a capability when a real requirement exists, not because the default configuration offers it.

Break-Glass First, Federation Second

The identity model started with its failure mode. Before any application trusted Authentik, the build notes fixed three rules.

The built-in akadmin account stays local. It is never federated, never disabled. Its password lives offline, multi-factor authentication is required, and a direct LAN login is tested. When the identity provider is the thing that is broken, this account is the way in.

Groups stay small and purposeful: authentik-admins with very few members and required MFA, and homelab-users for everyday applications. Service-specific groups exist only where an application needs a distinct boundary.

Every service keeps its own local administrator. The cutover procedure for each application makes that concrete: make sure that the local login works, add Authentik as a second login method, test login, logout, group denial, and an expired session, test the local break-glass account again, and only then make Authentik the normal path. Never migrate several critical services in one change window. If Authentik is unavailable, local administrative access over the LAN or Tailscale must still work.

The Immich Integration

Immich became the first full OIDC integration, and its configuration shows the shape of the pattern:

Issuer:        https://auth.example.com/application/o/immich/
Client type:   confidential
Grant:         Authorization Code only
Scopes:        openid email profile
Redirect URIs: https://photos.example.com/auth/login
               https://photos.example.com/user-settings
               app.immich:///oauth-callback

The third redirect URI is the reason this post exists — the mobile app’s callback, the thing Cloudflare Access can never serve. To let the apps reach the login flow, the photos hostname alone bypasses Access. The wildcard protection stays in place for every other service. Auto-register is off, so an Authentik account does not silently become an Immich account. And Immich password login stays enabled as the local recovery path, in line with the break-glass rule.

Web sign-in and iPhone sign-in were both verified after the cutover. The client secret lives in the password manager and in Immich’s encrypted settings, not in the repository.

Proof Before Trust: The Disposable Restore

The most valuable part of the Authentik rollout was not the deployment. It was the restore proof, one day later.

A backup script writes a checksummed recovery set: a PostgreSQL dump, an archive of the data, certificate, and template directories, and the protected environment file. The environment file matters more than it looks. It holds the Authentik secret key, and the build notes state the rule in one sentence: an archive that omits the secret key is not a complete Authentik backup. The database and the key belong to the same recovery set.

The proof restored that set into an isolated, disposable PostgreSQL and Authentik stack. One subtle step made the test honest. WebAuthn credentials bind to the relying-party identifier — the real login hostname. So Caddy briefly routed the production login hostname to the disposable stack. The restored akadmin account then completed a password login and a WebAuthn login, and reached the signed-in library. Caddy reloaded its unchanged production configuration, the production Authentik answered normally, Immich returned HTTP 200, and the disposable stack was removed.

That is the full circle: a backup, a restore, a real login against the restored system, and a clean return to production. The standing rule keeps it honest — a quarterly restore into a disposable stack, with a proven akadmin sign-in, before Authentik continues as a production dependency. Part 11 called an untested backup a hope. The identity service is where that slogan became procedure.

Machine Access, Bounded

One service account exists for unattended diagnostics. It sits in the built-in read-only group, has no browser password, and holds a 90-day API token. The verification probe was two-sided: the token can read the API, and a deliberate write attempt returned HTTP 403. A credential is not proven by what it can do. It is proven by what it cannot.

What This Stage Delivered

The platform now has one browser identity with MFA, native sign-in for mobile applications, and an identity service that was restored from backup before it was trusted. The integration order continues deliberately — the dashboard first, then Grafana with its local administrator retained, then further services one window at a time.

Next in the Series

Part 13 closes the roadmap with the operator layer: searchable container logs with Loki and Alloy, WUD watching registries next to Renovate’s pull requests, quiet-hours work lanes for risky storage jobs, and the morning digest that reports the platform’s health before the first coffee.