← all writing

Why my homelab runs Authentik instead of Keycloak

By the time I admitted I had twenty-something self-hosted services, I was managing twenty-something separate logins for them. Some had no auth at all — they were just trusting the LAN. Some had the same admin password as my router because I set them up the same evening. The day I realized I'd have to log into every single one of them individually if I ever wanted to revoke a person's access, the homelab had grown past "I'll remember" as a security model.

I needed what every enterprise has: single sign-on. One login, one place to manage users, one audit log, one place to enforce policy. Not because anyone else was using my homelab — but because the concepts transfer, and getting identity management right at home is much cheaper than getting it wrong somewhere that matters.

The default answer in this lane is Keycloak. The right answer, for me, was Authentik. This is why.

#The three real options

The self-hosted identity-provider space has three serious projects, and they're not interchangeable.

Keycloak is the established player. Java-based, battle-tested, used by big enterprises, deep feature set. The IBM/Red Hat sponsorship guarantees it'll outlive most of its competitors. It's also heavyweight, the admin UI feels like it was last redesigned in 2014, and the documentation assumes you already know enterprise identity management. The smallest reasonable Keycloak deployment is two JVM processes plus a database. On a homelab box already running fifteen other things, that math doesn't work the way it does at a Fortune 500.

Authelia is the simple one. It's designed primarily as a forward-auth backend for reverse proxies — point Nginx or Traefik at it, and Authelia decides whether each incoming request is authenticated. Lightweight, fast, easy to set up. It's also not really an identity provider in the OIDC sense. It can issue OIDC tokens for a few use cases, but if your goal is "give me one identity that every app can authenticate against using their own native OIDC integration," Authelia is structurally the wrong shape. It's a login wall. It's a good login wall. It's not what I needed.

Authentik is the newer entrant. Python-based, full-featured identity provider with OIDC, SAML, LDAP, and proxy outpost support. The admin UI is modern and the documentation is written for people who don't already know enterprise identity management — which is the rare and valuable thing. Runs as four Docker containers (server, worker, Postgres, Redis). The flow/stage architecture is the most interesting design choice — every login is composed of stages you can recombine. Want to require MFA only for admins, only on apps tagged sensitive, only when accessed from outside the LAN? You compose those rules from stages. It's the same primitive Okta uses, just open-source.

I picked Authentik for two reasons that I think are worth being explicit about.

The first is selfish: I wanted to learn enterprise identity management. The concepts in Authentik translate directly to Okta and Microsoft Entra. Provider-application separation, scopes, claims, flows, MFA enrollment, audit logging — they're all here, working the same way they would at a 5,000-person company. Keycloak teaches the same concepts, but with more cognitive overhead per concept. Authelia doesn't really teach them at all. If the goal is "I want to be able to walk into an enterprise environment and not be confused," Authentik is the most efficient teacher.

The second is practical: I trust modern Python more than I trust modern Java. Not because Java is bad, but because Keycloak's startup time, memory footprint, and operational shape feel disproportionate for a 130-employee company, much less a homelab. When I imagine the next ten years of running this — the upgrades, the diagnostics, the "why is auth slow today" moments — I'd rather be debugging Python on Postgres than debugging a JVM heap.

Both reasons are taste. Both are real.

#The architecture

Standard reverse-proxy-fronted setup. A wildcard cert via Cloudflare, a single Nginx Proxy Manager instance handling TLS termination and routing, and Authentik sitting at one of the subdomains:

              User's browser
                    │
                    ▼
          Nginx Proxy Manager
                    │
        ┌───────────┼─────────────────┐
        │           │                 │
        ▼           ▼                 ▼
   auth.            paperless.        komga.
   example.com      example.com       example.com
        │
        ▼
  Authentik server (:9000)
        │
        ├── Authentik worker (background tasks)
        ├── PostgreSQL (users, config, audit log)
        └── Redis (cache, message queue)

Every application redirects unauthenticated users to Authentik. Authentik handles the login, issues a token, and redirects back. The app reads the token, creates a session, and the user never types a password into the app itself.

That's the whole flow. The interesting work isn't the protocol — it's getting each app to participate.

#The DNS gotcha that every self-hosted SSO setup hits

Authentik came up clean on the first deploy. Five minutes from docker compose up to admin dashboard. The interesting work started when I tried to wire the first app to it.

I picked Paperless-NGX as the first integration. Paperless uses Django's allauth library for OIDC, so configuration is environment variables: client ID, client secret, the issuer URL pointing at Authentik's discovery endpoint. Restart the container. Open Paperless in an incognito window. See the "Sign in with Authentik" button. Click it.

Get redirected to Authentik. Log in. Get redirected back. Connection refused.

The Paperless container couldn't reach auth.example.com. The hostname resolved fine from the host. From inside the container, on Docker's default bridge network, it didn't resolve at all. Docker's bridge networking doesn't inherit the host's DNS, and even when it does, public DNS doesn't know about my reverse proxy's internal route. The container was looking up the public IP for the hostname, hitting the WAN, getting routed back through my own Cloudflare tunnel, and timing out before the loop completed.

The fix is extra_hosts in the compose file:

services:
  paperless:
    image: ghcr.io/paperless-ngx/paperless-ngx:latest
    extra_hosts:
      - "auth.example.com:172.20.0.5"

That maps the hostname to the Docker bridge gateway address inside that one container, so the container resolves auth traffic to a local route instead of bouncing through public DNS. Every app I connected to Authentik after that needed the same fix. Every. Single. One.

This is the gotcha that every self-hosted SSO tutorial glosses over and every homelab admin hits in their first hour. In a corporate environment with proper internal DNS, or with a dedicated identity provider on a different host, the problem doesn't exist. In a homelab where everything runs on the same Docker host and talks to itself through a reverse proxy hostname, the problem is universal. Write down the workaround somewhere. You'll use it again.

#OIDC is standardized; OIDC implementations aren't

The protocol is RFC-defined. The implementations are a matter of opinion.

Paperless configures OIDC via environment variables, auto-discovers everything from the issuer URL, and handles first-login user creation through Django's allauth defaults. Sane.

Komga configures OIDC via a YAML config file (application.yml), uses Spring Security's OAuth2 client, also auto-discovers from the issuer URL, but caches the discovery response so changes to the provider require a container restart. Mostly sane.

Audiobookshelf configures OIDC through its web UI. It does not auto-discover — every endpoint URL has to be entered individually (authorize, token, userinfo, JWKS, logout, end-session). It also disables auto-register by default, which means the first SSO login will succeed on Authentik's side and then fail on the app's side with "User not found and auto-register is disabled," which I spent twenty minutes debugging before finding the correct toggle.

Same protocol. Three different configuration models. Three different first-login behaviors. Three different ways to misconfigure.

This is the part of "OIDC experience" that doesn't show up in protocol specs. The standard tells you what fields to send and what fields to expect. It does not tell you which app expects the issuer URL with a trailing slash and which one rejects requests if you include one. It does not tell you which app silently falls back to a local user table when the OIDC dance fails versus which one renders a stack trace. The implementations are where the time goes.

If you ever see "experience with OIDC integration" on a job description, that's what they mean.

#What SSO actually buys you

The login experience is nicer. That's the smallest thing.

You stop reusing passwords. When every app delegates auth to Authentik, the only password that matters is the Authentik one. The passwords scattered across twenty apps either get deleted or become local-only fallbacks for emergency access. The blast radius of any one credential goes from "that app" to "nothing, because that app didn't have its own credential."

You get a real audit log. Every login attempt across every app shows up in one place, with timestamps, source IPs, and user agents. Anomalies are visible. Brute-force attempts are visible. Did anyone log into Paperless from outside the LAN this week? That's now a single query.

You can revoke access in one place. Disable a user in Authentik, and they lose access to every connected app immediately. No multi-app cleanup. No forgotten side accounts. No half-revoked-and-still-valid scenarios. This is the security control that scales — the difference between "I'll go disable that account, hold on, what apps did they have" and "done."

You get the foundation for everything else. MFA. Conditional access (require fresh auth for admin-tier apps). Group-based authorization (the family doesn't see the admin tools). Geofencing. Step-up auth on sensitive operations. None of these are individually new. All of them require an identity provider you control. You can't have any of them with twenty separate login systems.

The security improvement from setting up SSO is large the day you finish. The improvement compounds for every app you add afterward. Every new self-hosted service is one configuration change away from joining the audit log, instead of being one more password to forget.

#What's next

The next layers are the ones that turn this from "a login system" into "an identity platform."

Group-based access — Admins, Family, and Guest groups, each with different app visibility. MFA via TOTP and WebAuthn, with conditional policies that require the second factor for sensitive apps and skip it for media. A proxy outpost in front of the apps that have no native auth at all. Each of these is roughly an evening of work and a permanent improvement.

The longer-term goal isn't "have SSO at home." It's to understand identity management deeply enough that I can architect it for any organization — whether that means configuring Okta for a 300-person company, building OIDC integrations into internal tools, or making the call about which provider a startup should adopt before the integrations get expensive to undo.

You learn the most by running the boring parts. Authentik in a homelab is the boring parts at a scale where I can afford to break them.


← all writing