I spend my working life inside other people’s identity providers. Okta, Entra ID, Ping. I can navigate all three consoles from memory, and that is precisely what started bothering me, because what I know is three vendors’ interfaces: their words for a policy, their idea of where a claim comes from and what is allowed to shape it, and their defaults, which are usually good and which I have therefore never had a reason to question.
That is a real skill, and not the same skill as understanding what sits underneath. Debugging at the protocol layer is my job, and I do plenty of it. But I do it standing on a platform somebody else built and maintains. The directory already exists and something else keeps it in sync. Certificates are issued and rotated by a system I did not deploy. The identity provider is run by a vendor’s SRE team, its logs arrive in a pipeline I did not build, and its configuration surface has guardrails specifically designed to stop me making whole categories of mistake. When I work out why a token is missing a claim, I am working inside a machine that was already assembled, tuned and fenced before I got there.
Which leaves a question I could not answer: do I understand identity, or do I understand the scaffolding that has been holding it up for me? There is only one way to find out. Start from an empty host with no vendor, no platform team, no managed anything, and no guardrails, and see whether the thing works when every decision in it is mine.
I picked an identity provider I do not use at work and pointed it at my own infrastructure, where a broken authorization flow costs me an evening. authentik is not Okta, Entra or Ping, and that is the appeal. Nothing about it is familiar. There is nobody to escalate to. Every default is one I have to either justify or change, and the ones I got wrong, including a significant one further down this post, stayed wrong until I found them myself.
I also had a workload sitting there waiting for one. My media and automation stack grew the way these things always grow, one container at a time. Sonarr, Radarr, Prowlarr, Tautulli, Huntarr, Cleanuparr, Unpackerr, Seerr, a qBittorrent (for important Linux ISOs) instance on its own host, and an OpenMediaVault box holding the storage (would not recommend, but I’m in too deep). Nine or so services depending on how you count, every one of them behind nginx with a real certificate, and every one of them with its own login form, its own session, its own password in my password manager under a slightly different name.
That is nine authentication problems, and they are meaningfully different from each other. Most of them are passion projects. A few support delegated authentication properly and will trust a header from a proxy they trust. Others have their own network-based auth bypass, and the rest will insist on their own login no matter what you put in front of them. Working out which is which, and what each one does when a proxy starts making decisions on its behalf, turned out to be most of the actual work and most of what I learned.
This post covers standing the provider up. Later posts in the series cover putting Caddy in front of it, and then the two applications that turned out to be awkward, Tautulli and OpenMediaVault.
Picking an identity provider and a box to run it on
I looked at Authelia and Keycloak before landing here.
Keycloak is the enterprise answer and it is very good at being the enterprise answer. It is also a lot of machine for a house, and its mental model assumes you arrived with an existing directory and a compliance requirement. Authelia is the opposite trade: small, file-configured, fast, and lovely if forward auth is all you want. What tipped me to authentik was that I did not think forward auth was all I would want. I wanted the option of OIDC later for the applications that support it properly, LDAP for the ones that only speak that, and a real admin interface I could hand somebody else without teaching them YAML first.
The second reason is less about architecture and more about where the answers are. authentik is the identity provider that keeps coming up in self-hosting spaces, and that has practical value that has nothing to do with the software’s merits. When something breaks at eleven at night, the useful question is whether somebody has already hit this and written it down. For authentik the answer is usually yes, in a forum thread, a reddit post, or somebody’s blog. For a person working alone that beats most feature comparisons.
And then the price.
The self-hosted open source tier is free. It is not a crippled demo of a real product. OIDC, SAML, LDAP, SCIM, RADIUS, proxy providers, the policy engine, MFA, the flow designer: all of it is in the free tier, with no user cap and no feature gate on the protocols. What the paid tiers add is support and a handful of enterprise integrations. The protocols are all in the free one.
If I ever did want the commercial tier, authentik Enterprise is $5 per internal user per month, billed annually, with external users at $0.02 each and an Enterprise Plus tier starting at a $20,000 per year floor for FIPS and multi-instance deployments. Set that against Okta Workforce starting at $6 per user per month and Entra ID at $7, and note that those are starting numbers, and organizations end up paying more once governance, lifecycle and API access are added.
For me the number is zero, because it is one user in a house. But the number being five and not a phone call is the point: what I am learning on is the same thing I could put in front of a small team without a procurement conversation. The free tier is not a sandbox I would have to grow out of. That is a different proposition from learning on a personal-use license that evaporates the moment the deployment becomes real.
The hardware is a Dell Wyse 5070, one of the small fanless thin clients that show up on the secondary market for less than what a Raspberry Pi costs, since the foundation lost their mind with prices. It runs Ubuntu on bare metal at 192.168.66.6. The host not being virtualized is deliberate and it is the first thing I would tell somebody to copy (if your budget allows).
Everything else in my lab runs as a Proxmox guest. The identity provider does not, because the identity provider must be up when other things are down. If authentik lives inside the virtualisation cluster, then a cluster problem is also an authentication problem, and an authentication problem means I cannot log into the web interface of the thing I need to fix. Bare metal on a separate small box breaks that loop. It also means the box has exactly one job, which makes it a much easier thing to reason about and a much easier thing to rebuild.
The traffic split falls out of that layout. Services that live on my application host at 192.168.66.11 are proxied there by the nginx that was already in front of them. Everything else goes through .6, which hosts authentik and is therefore already a dependency for all of it. Machine-to-machine traffic, the API calls my dashboard makes and the calls the arr apps make to each other, skips both proxies entirely and talks to upstreams directly. That last part is most of what the later posts are about.
Vocabulary before configuration
There are four authentik words in this post, and the compose file does not make sense until you have them. If you already know them, skip ahead. If you do not, nothing below assumes you looked them up elsewhere.
An application is authentik’s record of a thing you want to protect. It is mostly a name, a slug and a link. On its own it does nothing.
A provider is attached to an application and defines how that protection happens: OIDC, SAML, LDAP, RADIUS, or a proxy provider for applications that speak none of those. The provider is where the real configuration lives.
Forward auth is the mechanism I use throughout this series, and it works like this. A reverse proxy sits in front of your application. Before passing a request through, the proxy pauses and asks a separate service, out of band, whether this request is allowed. If the answer is yes, the proxy forwards the request and usually adds some headers describing who the user is. If the answer is no, the proxy sends the browser off to log in. The application itself is never modified and often never learns any of this happened. nginx spells this auth_request, Caddy spells it forward_auth, and Traefik calls it a forward auth middleware, but it is the same idea in all three.
An outpost answers that question.
This piece is the least obvious of the four, so I will go slowly. The authentik server you log into is not in the path of your application traffic. It holds users, groups, policies, flows and provider definitions, and it renders a login page, but requests to your media dashboard do not pass through it. Something has to sit at the edge, see each incoming request, check whether it carries a valid session, and either wave it through with identity headers attached or bounce it to the login page. That something is an outpost.
An outpost is a small component that connects back to the authentik core over its API and a WebSocket, receives a copy of the configuration for whichever providers are assigned to it, and then enforces that configuration at the edge. Its job covers the /outpost.goauthentik.io/ endpoints your proxy talks to, validating and issuing the session cookie, redirecting to the login flow, and emitting the X-Authentik-* headers your application may or may not care about. Proxy outposts do that for HTTP. There are also LDAP and RADIUS outposts, which do the equivalent for those protocols.
Outposts come in two shapes, and choosing between them is the decision in front of you.
The embedded outpost runs inside the authentik server container. It ships enabled, requires no deployment of its own, and is reachable on the same port the server already listens on. Because it is part of the server image, it is always exactly the same version as the core.
A standalone outpost is a separate container or process, deployed independently, pointed at your authentik instance with a token. You would want one when the application you are protecting lives somewhere the authentik core should not be reachable from, such as a DMZ or a different site, or when you need to scale enforcement separately from the core, or when you want the edge to keep working through a core restart.
None of those apply to a single-host lab. Everything I am protecting is either on the same box as authentik or one hop away on my own LAN, requests arrive in one place, and there is no network boundary I am unwilling to cross, no scaling pressure, and no availability requirement beyond “it is usually up”. A standalone outpost would add a container to deploy, a token to manage, a second thing to upgrade, and a version to keep in lockstep, in exchange for nothing I can name.
I run the embedded outpost only, and that single choice explains a lot of what follows: why the Docker socket in the upstream compose file turned out to be unnecessary, why the authentik ports have to be reachable from my LAN rather than only from localhost, and why pinning the image version protects me from a class of failure I would otherwise have to think about.
The project is explicit about that last one. An outpost and the authentik core it connects to must be running the same version. With the embedded outpost you get that for free, since they are literally the same container. Run a standalone outpost and it becomes a real operational constraint: every core upgrade requires upgrading the outpost too, and a version mismatch is an unsupported configuration whose symptom is authentication failing everywhere at once.
The compose stack
authentik publishes a reference docker-compose.yml per version. Mine is derived from the 2026.8.0 one, copied nowhere, and the header records that so the next person to diff it knows where to look:
The assembled file is in my TechbyJeff repo at Docker/authentik/docker-compose.yml. That copy carries a longer header than the one above, aimed at somebody reading it cold, and the ACME account email is a required variable there and carries no default. Everything below is that same file, in pieces.
The first service is the database:
| |
Interpolation has to make sense before the rest of the file does. Before Compose reads this as configuration, it does a substitution pass over the text: anything written ${SOMETHING} is replaced with a value taken from the environment, or from the .env file sitting next to the compose file. What Compose then parses is the result of that substitution. A variable that resolves to nothing produces an empty value, and the container starts anyway.
What you write after the variable name is what decides that behaviour:
| Written as | Variable is set | Variable is unset or empty |
|---|---|---|
${PG_DB} | its value | empty string, silently |
${PG_DB:-authentik} | its value | authentik |
${PG_PASS:?database password required} | its value | Compose stops and prints that message |
The message after :? is a sentence I wrote. Compose prints it back to me when the check fires, so make it say what is missing.
Emptying PG_PASS out of the .env and running docker compose config shows both halves of this at once:
| |
Two warnings and one error, from the same missing variable. The error is my sentence, handed back with the exact path to the setting that wanted it. docker compose config interpolates and validates without starting anything, which makes it the cheap way to check a file before you inflict it on a running host.
A missing database name is a thing I am happy to default. A missing database password should stop the deployment dead, because the alternative is a Postgres container with an empty password. Every secret in my file uses the :? form. Everything with a sensible default uses :-.
The colon does real work here and is easy to drop. ${PG_PASS?...} without it only fires when the variable is missing entirely; set PG_PASS= to nothing at all and it sails through, because the variable technically exists. :? treats unset and empty the same way, which is the behaviour you want from a guard. The same is true of :- against -.
One thing further down the file looks inconsistent and is not. The server and worker services refer to ${PG_PASS} bare, with no guard on them at all. Those two are the warnings above: Compose interpolates them to a blank string and tells you it did. What stops that mattering is that interpolation is a whole-file operation and the run fails as a unit, so the blank never reaches a container. Guarding a secret once per file is enough, and repeating the guard on every use just means writing the same sentence three times and reading it three times when it fires.
The healthcheck’s $${POSTGRES_DB} is the other escaping trap. A single $ would be expanded by Compose on the host, where POSTGRES_DB means nothing. The doubled $$ escapes it so a literal ${POSTGRES_DB} reaches the container, where the shell running pg_isready expands it against the environment the container has. Get this wrong and your healthcheck runs pg_isready -d -U, which fails in a way that looks like a database problem, not a quoting problem.
Older guides will lead you wrong here. Postgres is the complete list of stateful services in my stack. There is no Redis.
If you follow a walkthrough written before late 2025, it will have you stand up a Redis container, and it will be wrong. authentik removed the Redis dependency in the 2025.10 release, migrating caching, the embedded outpost’s session store, and WebSocket communication to Postgres, having already moved background tasks there in 2025.8. The project’s own writeup on the change notes that the tradeoff is roughly 50% more database connections to Postgres, which on a single-user homelab instance is a rounding error and on a large deployment is a capacity planning item.
If you are upgrading an existing install, delete the Redis service and the Redis settings, and run the upgrade with --remove-orphans so the old container goes away instead of sitting there burning memory for nothing.
The server itself is unremarkable, and I will come back to its ports block in a moment because that is where I made my mistake:
| |
The double underscore in AUTHENTIK_POSTGRESQL__HOST is authentik’s convention for nesting. It maps to postgresql.host in the configuration tree. Any setting in authentik’s documented config file can be set this way, which is how it stays configurable from a compose file without ever writing a config file.
The worker is the same image with a different command. Upstream’s version of this service is the one place in the reference compose file I would tell you to stop and read before copying. Mine after I changed it:
| |
The upstream file has two more lines than that, and they are these:
Mounting the Docker socket into a container is handing that container root on the host. There is no softer way to put it: anything that can talk to /var/run/docker.sock can start a privileged container that mounts /, and at that point it is root on your machine. Running the process as root on top of that removes the last speed bump.
This is not a mistake in the upstream file. It is there so authentik can manage outpost containers for you, creating and updating them on the Docker host as you define providers. If you use that feature, you need it.
Most single-host installs never use that feature. authentik ships an embedded outpost that runs inside the server container, and for forward auth against a proxy on the same box, the embedded outpost is all you need. So the default hands host root to a component that, in the common case, has nothing to do with it.
I ran with the upstream configuration first, then went back and removed both lines. What made that safe was checking, and the checks are quick enough that there is no excuse not to run them.
Before removing anything, confirm you have no Docker-managed outposts. In the admin interface under Applications, Outposts, look at what exists and what service connection each one uses. Mine had one outpost, the embedded one, with no service connection. If yours lists an outpost bound to a Docker connection, that outpost is managed through the socket and removing the mount will break it.
Then check who the image runs as, and whether your bind-mounted directories match:
The image’s default user is uid 1000, and my data, certs and custom-templates directories were already owned by 1000:1000. That decides whether dropping user: root costs you anything. If those directories are owned by root because the container created them while running as root, the worker will lose the ability to write to them and you will need to chown first. Check before, not after.
After the change, three things confirm it worked:
And in the worker log, watch for the outpost controller task completing. Mine logs outpost_controller running against the embedded outpost and finishing cleanly, which is the actual proof the socket was never needed: the controller still does its job, because the outpost it is controlling does not live in Docker.
One loose end that will confuse somebody. authentik’s default blueprints create a service connection object called “Local Docker connection”, pointing at /var/run/docker.sock. Removing the mount does not remove that object, and it does not remove it from the dropdown when you create an outpost. It sits in the interface looking perfectly usable. If you later wire an outpost to it, you get failures that do not obviously trace back to a bind mount you deleted weeks earlier. Either delete the connection object too, or leave yourself a note.
Keep a copy of the original before you change it. Mine is docker-compose.yml.bak-dockersock, named for exactly what it contains, because the day I do want Docker-managed outposts is the day I will want those two lines back and will not remember which two they were.
The general lesson outlives authentik. Reference compose files are written to make every feature work for everybody, which means they are written for the most privileged case. Read the volumes and user lines of anything you are about to run before you paste it, and ask which feature each one enables and whether you use that feature.
The remaining two services are cloudflared, which runs my tunnel, and a custom Caddy build. Both were added after the initial deployment, and both are the subject of the next post. For now, just the shape:
--no-autoupdate with a pinned image tag is the combination you want in a compose stack. A binary that updates itself inside a container whose image you pinned no longer matches its own tag, and the next time you recreate it you roll back to whatever the image contains. Pin the image, disable the self-updater, upgrade by changing the tag. Don’t have a watchtower sidecar on this. Cannot stress you don’t want your idp to change without testing/validating.
The secrets file
Everything sensitive lives in .env next to the compose file, and nowhere else. Mine opens with a comment recording when it was generated:
| |
The keys in it are AUTHENTIK_SECRET_KEY, PG_PASS, PG_USER, PG_DB, AUTHENTIK_TAG, COMPOSE_PORT_HTTP, COMPOSE_PORT_HTTPS, AUTHENTIK_ERROR_REPORTING__ENABLED, CLOUDFLARED_TOKEN, CF_API_TOKEN, and ACME_EMAIL. Three of those deserve a sentence each.
AUTHENTIK_TAG is pinned to an explicit version, with a comment in the file explaining why: upgrades should be a thing I decide to do, never a thing that happens because a container restarted at three in the morning after a power blip. For an identity provider the stakes are higher, because what breaks is what you would use to log in and fix it. It also interacts with the version parity rule from the vocabulary section. I am insulated from that today because my only outpost is embedded and therefore always matches, but the day I add a standalone outpost, a floating tag becomes a mechanism for putting core and outpost on different versions.
AUTHENTIK_ERROR_REPORTING__ENABLED is set, with the comment # Don't phone home. It is on by default. It is not malicious and the project is upfront about it, but a self-hosted identity provider is a strange place to accept default telemetry, and turning it off is one line.
COMPOSE_PORT_HTTP and COMPOSE_PORT_HTTPS are the host ports the server publishes, defaulting to 9000 and 9443. Which brings me to the part I got wrong.
Whatever you do, make sure that file is not world readable. Mine is 0600, owned by the account that runs the stack. Compose reads it as that user, so nothing else needs access to it. And it does not go in a git repository, ever, including private ones, because “private” is a setting somebody can change and a mistake somebody can make.
Every interface on the box
Here is the change I made to the server’s ports block after the first deployment:
The upstream compose file publishes those ports in the short form, 9000:9000. The short form means every interface. Not the LAN interface, every interface: the LAN address, the Tailscale address, any bridge, any VPN adapter, anything the host has now or grows later.
That alone would be a mild concern. The second half surprised me the first time I met it, and I suspect it surprises a lot of people who believe they are protected.
Docker’s published ports are not filtered by ufw. Docker writes its own rules into the DOCKER chain in nftables, and that chain is consulted before the INPUT chain where ufw’s rules live. A host with ufw status reporting a tidy deny-by-default policy, and no rule permitting 9000, will happily serve port 9000 to anyone who asks. You can verify it yourself in about ten seconds from another machine, and I recommend you do, because reading it is not the same as watching your firewall not work.
The fix is the one in the diff. Prefixing the host port with an address makes Docker bind to that address only. Everything else on the box stops being a listener.
The obvious question is why I bound it to the LAN address instead of 127.0.0.1, which is stricter. The answer is that it cannot be localhost in my layout, and the reason shapes the rest of the series. My nginx instance on 192.168.66.11 performs forward auth by making subrequests to http://192.168.66.6:9000/outpost.goauthentik.io/auth/nginx. That is a cross-host call to the embedded outpost. If .6 only listens on loopback, every one of those subrequests fails, and every application behind that nginx stops authenticating. The .6 host therefore has to be reachable on the LAN for exactly that purpose, and the correct scope is “the LAN address, and nothing else.”
You can see the result on the host:
9000 and 9443 are pinned to one address. 80 and 443 are wide open on purpose, because that is Caddy, and Caddy is supposed to be the front door.
After every compose change that touches ports, run ss -tlnp on the host and read the local address column. Not the port column, the address column. It is the only place the difference between 9000:9000 and 192.168.66.6:9000:9000 is visible.
First boot
With the file in place, the deployment itself is unexciting, which is how it should be:
| |
Postgres comes up, passes its healthcheck, and the server and worker start behind it because of the depends_on with condition: service_healthy. That condition does real work. Without it the server races the database, fails its migrations, restarts, and you get to watch a container crash-loop for a minute while you wonder what you broke.
The first thing you do afterwards is visit /if/flow/initial-setup/ on the server, which is a one-time flow that exists to create your first administrator.
A word on flows, since they are authentik’s central idea and everything else in the product is arranged around them. A flow is an ordered sequence of steps that a user is walked through, and each step is called a stage. Prompting for a username is a stage. Prompting for a password is a stage. Checking a TOTP code, showing a consent screen, writing the resulting user to the database: all stages. A login is not a single hardcoded behaviour in authentik, it is a flow made of stages that you can reorder, remove or add to. Policies can be bound to a flow or to an individual stage to decide whether it applies to a given user at all, which is how you get things like “prompt for MFA only from outside the LAN” without writing code.
The initial setup flow is a real flow like any other, designated stage_configuration, bound to a require_superuser policy so it stops being reachable once the account it creates exists. That is a nicer design than a bootstrap token in a log file. The flow list you inherit on a fresh install looks like this:
| Slug | Purpose |
|---|---|
initial-setup | One-time admin creation, superuser-gated |
default-authentication-flow | The login your users actually see |
default-invalidation-flow | Logout |
default-provider-authorization-implicit-consent | Authorize an application without prompting |
default-provider-authorization-explicit-consent | Authorize an application with a consent screen |
default-authenticator-totp-setup | Enroll TOTP |
default-authenticator-webauthn-setup | Enroll a passkey or security key |
default-password-change | Self-service password change |
Fifteen flows ship by default. The eight above are the ones you will touch. The distinction between implicit and explicit consent is the one to internalize early, because it decides whether adding an application to a user’s portal silently grants access or shows them an authorization prompt first. For services I own end to end, implicit is right. For anything I might one day expose to somebody who is not me, explicit is right.
You also get three groups out of the box: authentik Admins, which is the superuser group, plus authentik Agent-Users and authentik Read-only, neither of which is superuser. Your bootstrap account, akadmin, lands in the first one.
Resist the urge to keep using akadmin as your day-to-day login. Make yourself a normal user, put it in authentik Admins if you need admin, and leave the bootstrap account alone as a break-glass credential with a long password you do not have memorised. The reason is the same reason you do not run as root: the day you lock yourself out of your own identity provider by breaking a flow you are editing, you will want an account whose authentication path you have not touched.
What you have at this point
A working identity provider with a login page, an admin interface, and nothing behind it. That is all. authentik does not front anything until you define a provider and an application, and it does not front anything over HTTPS until you put a reverse proxy in front of it.
The embedded outpost does the fronting. It ships enabled and runs inside the server container. Its configuration is stored as JSON on the outpost object, and the field to set is authentik_host, which is the externally reachable URL the outpost hands out for redirects. Set it to something a browser cannot reach and you get a login loop that looks like an application problem and is not.
The next thing I did was put Caddy in front of all of it and get real certificates for names that have no public DNS records at all. That turned out to be the easy half. Handing an application a verified identity in a header is straightforward; whether the application does anything useful with it varies enormously, and that is where the rest of this series goes.
Get it
The compose stack is in my TechbyJeff repo at Docker/authentik/docker-compose.yml, derived from the upstream 2026.8.0 file with the port bindings scoped to a single address, no redis service, and no Docker socket mount on the worker. Before it runs anywhere but my house, change the bind address on the server ports, which is my LAN address and not yours, and ACME_EMAIL, which is a required variable there rather than carrying a default. The .env it reads is not in that repo and should not be in yours.
Sources
- authentik Pricing
- The Okta Tax: How Much Are You Really Paying for Identity?, authentik blog
- Microsoft Entra pricing
- Outposts, authentik documentation
- Embedded Outpost, authentik documentation
- Upgrade authentik, on outposts matching the core version
- Release 2025.10, authentik release notes
- We removed Redis, authentik blog
- Flows, authentik documentation
- Compose file interpolation, Docker documentation
- Compose file services, the
portsshort syntax - Packet filtering and firewalls, Docker documentation
- Docker Engine security, the daemon attack surface
Part one of a series on the identity stack in my homelab. Next: certificates, then forward auth and exposing the identity provider. Tautulli and OpenMediaVault come after that, then Proxmox over OIDC.
