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 the thing that started bothering me, because what I actually 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. Their defaults, which are usually good, and which I have therefore never had a reason to question.

That is a real skill and it is 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 an honest 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, and it is to 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.

So 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 instead of a change record. authentik is not Okta, Entra or Ping, and that is the appeal rather than a compromise. 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 not one authentication problem, it is nine, and they are meaningfully different from each other. Most of them are passion projects. Some of those applications support delegated authentication properly and will trust a header from a proxy they trust. Some have their own network-based auth bypass. Some have neither and 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 genuinely awkward, Tautulli and OpenMediaVault.

Why authentik, and why a thin client

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 genuinely 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 not which product is theoretically best, it 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. That is worth more to a person working alone than most feature comparisons.

And then there is the price, which is the part I want to be precise about because it is unusual.

The self-hosted open source tier is free, and 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 rather than the ability to actually do identity.

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 the starting numbers rather than what an organization ends up paying once governance, lifecycle and API access are added.

For me the number is zero, because it is one user in a house. But it matters that the number would be five and not a phone call, because it means the thing 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 is the thing that 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 matters more than it sounds like it should, and it is most of what the later posts are about.

The vocabulary, before the compose file

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 is the thing that answers that question.

This is the piece that is genuinely non-obvious, so it is worth being slow about. 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. It handles the /outpost.goauthentik.io/ endpoints your proxy talks to, it validates and issues the session cookie, it performs the redirect to the login flow, and it emits the X-Authentik-* headers your application may or may not care about. Proxy outposts do that job 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 that matters here.

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, and this is the answer to “which do I need”. Everything I am protecting is either on the same box as authentik or one hop away on my own LAN. There is one place requests arrive. 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.

So I run the embedded outpost only, and that single choice explains three things later in this post: 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.

That last one deserves its own sentence, because the project is explicit about it. 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 rather than copied, and the header of my file says exactly that so future me knows where to diff against:

1
2
# authentik - derived from https://goauthentik.io/docker-compose.yml (2026.8.0)
# Values come from .env in this directory.

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 rather than my address as a default. Everything below is that same file, in pieces.

The first service is the database, and there is one detail in it worth pausing on:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
  postgresql:
    image: docker.io/library/postgres:16-alpine
    restart: unless-stopped
    shm_size: 256mb
    environment:
      POSTGRES_DB: ${PG_DB:-authentik}
      POSTGRES_USER: ${PG_USER:-authentik}
      POSTGRES_PASSWORD: ${PG_PASS:?database password required}
    volumes:
      - database:/var/lib/postgresql/data
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
      start_period: 20s
      interval: 30s
      timeout: 5s
      retries: 5
    env_file:
      - .env

Two pieces of Compose syntax are doing real work there.

The first is interpolation, which is the thing to understand before the rest of the file makes sense. 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. So a variable that resolves to nothing does not produce an error, it produces an empty value, and the container starts anyway.

What you write after the variable name is what decides that behaviour:

Written asVariable is setVariable is unset or empty
${PG_DB}its valueempty string, silently
${PG_DB:-authentik}its valueauthentik
${PG_PASS:?database password required}its valueCompose stops and prints that message

The message after :? is not a Compose keyword, it is a sentence I wrote. Compose prints it back to me when the check fires, so it is worth making it say what is actually missing.

Emptying PG_PASS out of the .env and running docker compose config shows both halves of this at once:

1
2
3
time="2026-09-02T04:13:22Z" level=warning msg="The \"PG_PASS\" variable is not set. Defaulting to a blank string."
time="2026-09-02T04:13:22Z" level=warning msg="The \"PG_PASS\" variable is not set. Defaulting to a blank string."
error while interpolating services.postgresql.environment.POSTGRES_PASSWORD: required variable PG_PASS is missing a value: database password required

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 is worth knowing on its own, incidentally: it interpolates and validates without starting anything, so it is the cheap way to check a file before you inflict it on a running host.

The distinction is the whole point: a missing database name is a thing I am happy to default, and a missing database password is a thing that should stop the deployment dead rather than silently produce a Postgres container with an empty password. Every secret in my file uses the :? form. Everything with a sensible default uses :-.

The colon is load-bearing and 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.

$${POSTGRES_DB} in the healthcheck is the other one. 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 actually has. Get this wrong and your healthcheck runs pg_isready -d -U, which fails in a way that looks like a database problem rather than a quoting problem.

Now the part that catches people coming from older guides. Here is the complete list of stateful services in my stack: Postgres. That is it. 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.

So if you are upgrading rather than starting fresh, delete the Redis service and the Redis settings, and run the upgrade with --remove-orphans so the old container actually 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:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
16
17
18
19
  server:
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.8.0}
    restart: unless-stopped
    command: server
    shm_size: 512mb
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
    volumes:
      - ./data:/data
      - ./custom-templates:/templates
    depends_on:
      postgresql:
        condition: service_healthy
    env_file:
      - .env

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 the whole thing 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, so here is mine after I changed it:

 1
 2
 3
 4
 5
 6
 7
 8
 9
10
11
12
13
14
15
  worker:
    image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.8.0}
    restart: unless-stopped
    command: worker
    shm_size: 512mb
    environment:
      AUTHENTIK_POSTGRESQL__HOST: postgresql
      AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
      AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
      AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS}
      AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
    volumes:
      - ./data:/data
      - ./certs:/certs
      - ./custom-templates:/templates

The upstream file has two more lines than that, and they are these:

1
2
3
    user: root
    volumes:
      - /var/run/docker.sock:/var/run/docker.sock

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.

The thing worth noticing is that 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 rather than assuming, 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 actually runs as, and whether your bind-mounted directories match:

1
2
docker run --rm --entrypoint id ghcr.io/goauthentik/server:2026.8.0
ls -ln ~/authentik

The image’s default user is uid 1000, and my data, certs and custom-templates directories were already owned by 1000:1000. That is the part 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:

1
2
3
4
5
docker exec authentik-worker-1 id
# uid=1000(authentik) gid=1000(authentik) groups=1000(authentik)

docker inspect authentik-worker-1 --format '{{json .HostConfig.Binds}}'
# no docker.sock in the list

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 the only thing worth noting is the shape:

1
2
3
4
5
6
7
8
9
  cloudflared:
    image: cloudflare/cloudflared:2026.8.3
    restart: unless-stopped
    command: tunnel --no-autoupdate run
    environment:
      TUNNEL_TOKEN: ${CLOUDFLARED_TOKEN:?cloudflare tunnel token required}
    depends_on:
      server:
        condition: service_healthy

--no-autoupdate with a pinned image tag is the combination you want in a compose stack. Letting the binary update itself inside a container whose image you pinned gives you a container that no longer matches its own tag, and the next time you recreate it you silently roll back to whatever the image actually contains. Pin the image, disable the self-updater, and 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 I wrote for the version of me who finds this in two years:

1
# authentik - generated 2026-09-01T03:31:13+00:00

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 rather than floating on latest, with a comment in the file explaining why: upgrades should be a thing I decide to do, not a thing that happens to me because a container restarted at three in the morning after a power blip. For an identity provider that matters more than for most software, because the thing that breaks is the thing 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 silently 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, not even a private one, because “private” is a setting somebody can change and a mistake somebody can make.

The line I got wrong

Here is the change I made to the server’s ports block after the first deployment:

1
2
3
4
5
     ports:
-      - "${COMPOSE_PORT_HTTP:-9000}:9000"
-      - "${COMPOSE_PORT_HTTPS:-9443}:9443"
+      - "192.168.66.6:${COMPOSE_PORT_HTTP:-9000}:9000"
+      - "192.168.66.6:${COMPOSE_PORT_HTTPS:-9443}:9443"

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. What makes it worth a section is the second half, which is the thing that genuinely surprised me the first time I met it and which I suspect surprises a lot of people who think 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. So 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 is worth understanding because it 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. So .6 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:

1
2
3
4
LISTEN 0  4096  192.168.66.6:9443  0.0.0.0:*
LISTEN 0  4096  192.168.66.6:9000  0.0.0.0:*
LISTEN 0  4096       0.0.0.0:443   0.0.0.0:*
LISTEN 0  4096       0.0.0.0:80    0.0.0.0:*

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.

If you take one operational habit from this post, take this one: 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, and it takes two seconds.

First boot

With the file in place, the deployment itself is unexciting, which is how it should be:

1
docker compose up -d

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 is not decoration. 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, and it is 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, and it means the flow list you inherit on a fresh install looks like this:

SlugPurpose
initial-setupOne-time admin creation, superuser-gated
default-authentication-flowThe login your users actually see
default-invalidation-flowLogout
default-provider-authorization-implicit-consentAuthorize an application without prompting
default-provider-authorization-explicit-consentAuthorize an application with a consent screen
default-authenticator-totp-setupEnroll TOTP
default-authenticator-webauthn-setupEnroll a passkey or security key
default-password-changeSelf-service password change

Fifteen flows ship by default. The eight above are the ones you will actually 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 genuinely 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 piece that does the actual fronting is the embedded outpost, which ships enabled and runs inside the server container rather than as a separate deployment. Its configuration is stored as JSON on the outpost object, and the field that matters 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.

Which is the natural place to stop, because 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.

That is the next post.

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. Two things need changing before it will run anywhere but my house: 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


This is part one of a series on the identity stack in my homelab. Part two covers Caddy, certificate issuance for names the internet cannot resolve, and wiring forward auth. Part three covers Cloudflare, and what changes when the identity provider becomes the one internet-facing thing you own. Part four covers Tautulli, which has its own login it does not want to give up. Part five covers OpenMediaVault, which has no delegated authentication mode at all.