Authelia to authentik behind Traefik, without downtime
How do I migrate from Authelia to authentik behind Traefik without downtime?
I wanted a web interface to manage the users of my homelab, and above all the special cases: user1 should see three services and not the other twenty. In Authelia that is an ordered list of rules in a YAML file. authentik looked like the simplest option with a real interface for it, so in September 2026 I moved.
Traefik sits in front of about twenty services that have no login of their own, and before it passes a request on, it asks the identity provider who is calling. Five more apps (Forgejo, Mealie, Grimmory, Dockhand and Tessera) have real accounts and log in with OIDC. Authelia did both jobs, configured from the Ansible repo of part 2.
Without downtime, here, means that the two ran side by side for one night, and a service moved only after the new path had worked on it.
Start authentik next to Authelia, protecting nothing
Since release 2025.10 authentik keeps its cache, its tasks and the embedded outpost in PostgreSQL, and Redis is gone. The stack is a server and a worker against the PostgreSQL I already run, and right after the first deploy they used 639 MB together.
authentik’s own login page has a router with no forward-auth on it, so whatever happened to Authelia, I could always reach authentik.
Groups, users, providers and applications are YAML blueprints in the repo. Passwords and second factors live only in authentik’s database.
Move one harmless service, then the rest
For a few hours Traefik had both middlewares, authelia@file and authentik@file, and a service moved by changing which one its router mounts. The first was storyscraper, picked because nothing depends on it.
Forward-auth needs one more route. After the login, authentik sends the browser to https://<service>/outpost.goauthentik.io/callback to set the cookie on the service’s own host. That request has to land on authentik whatever the host, with a priority high enough to beat each service’s Host() rule. If the route is missing, you log in fine and then get a 404 on the way back. authentik’s Traefik example adds one such router per application; I wrote a single one for every host:
traefik.http.routers.authentik-outpost.rule: "HostRegexp(`^.+$`) && PathPrefix(`/outpost.goauthentik.io/`)"traefik.http.routers.authentik-outpost.priority: "1000"traefik.http.routers.authentik-outpost.entrypoints: "websecure"traefik.http.routers.authentik-outpost.service: "authentik"traefik.http.routers.authentik-outpost.middlewares: "{{ traefik_noauth_middlewares }}"When the login on storyscraper worked end to end, 21 routers moved in one commit. A search and replace on the templates missed four of them: three used the middleware variable inside a Jinja condition, and one overrode it in its own list entry. grep said the job was done. Traefik’s API, asked for the middlewares of the routers that were really running, found the four.
Grafana and Paperless went last, because they are the only two that read the user from a header, and authentik’s headers have other names. Moving their middleware without touching their configuration would have kept them up, with no idea who is logged in. Grafana has auto sign-up on, so a wrong header there creates new users instead of failing:
GF_AUTH_PROXY_ENABLED: "true"GF_AUTH_PROXY_HEADER_NAME: "Remote-User"GF_AUTH_PROXY_HEADER_NAME: "X-authentik-username"GF_AUTH_PROXY_HEADER_PROPERTY: "username"GF_AUTH_PROXY_AUTO_SIGN_UP: "true"GF_AUTH_PROXY_HEADERS: "Email:Remote-Email Name:Remote-Name"GF_AUTH_PROXY_HEADERS: "Email:X-authentik-email Name:X-authentik-name Groups:X-authentik-groups"Paperless finds its admin by username, so before the deploy I checked that my username in authentik was the same as PAPERLESS_ADMIN_USER. If the two had differed, Paperless would have created a second, empty account and logged me into that one.
Between the commit that started authentik and the one that moved the last forward-auth service there are 37 minutes. One service came back later. Lumora, one of my own apps, only looked at Remote-User. Behind authentik that header never arrived, every API call got a 401, and the frontend showed an empty page instead of an error. Lumora went back to Authelia until a new release of the app read X-authentik-username first and fell back to Remote-User. Traefik only overwrites the headers listed in authResponseHeaders, so a client can still send its own Remote-User and the app gets it untouched. Lumora’s routers now strip it.
One provider per service
My first version used a single provider for the whole domain, authentik’s forward_domain mode, because it is the closest thing to Authelia’s catch-all rule on *.example.com. For my case it was wrong. authentik’s docs say it directly: domain-level forward auth “cannot enforce different application-level authorization rules for each protected application”. Whoever can log in reaches everything, and user1 was the reason for the migration. I redid it the same evening.
Now every service has its own provider in forward_single mode and its own application, generated from one list in the inventory:
- {slug: seerr, name: "Seerr", host: "{{ seerr_host }}", groups: [homelab-admins]} - {slug: shelfmark, name: "Shelfmark", host: "{{ shelfmark_host }}", groups: [homelab-admins, limited]}The blueprint turns each entry into a provider, an application, and one policy binding per group:
{% for app in authentik_protected_apps %} - model: authentik_providers_proxy.proxyprovider id: provider-{{ app.slug }} identifiers: name: "forward-auth-{{ app.slug }}" attrs: mode: forward_single external_host: "https://{{ app.host }}" # ...{% for group in app.groups %} - model: authentik_policies.policybinding identifiers: target: !KeyOf app-{{ app.slug }} order: {{ loop.index0 }} attrs: group: !Find [authentik_core.group, [name, {{ group }}]] enabled: true{% endfor %}{% endfor %}external_host has to match the router’s Host() exactly. In single mode it is how the outpost knows which application a request is for.
In Authelia the first matching rule won, and user1’s access was written by exclusion: a policy that denied the limited group everything except Grimmory. In authentik each application has its own bindings, and they say who gets in. It is easier to read, with one trap: an application with no bindings at all is open to every logged-in user, and nothing in the interface flags it. That is why every entry in the list declares its groups.
Blueprints that report success
A blueprint with status successful has not necessarily done anything. That night I found three ways.
A !Find inside identifiers is not resolved. The object is not created, and the blueprint reports success anyway. This is why group sits in attrs in the bindings above.
Files apply in name order, and that order matters. The brand blueprint looked up the recovery flow with a !Find, the recovery flow was in a file that came later, and the brand ended up without a recovery link, again with no error. The file is now called 15-flows-recovery.yaml. The blueprint docs say the order is not guaranteed at all and suggest meta models when blueprints depend on each other.
An OIDC provider created by a blueprint has no grant types. The UI fills in defaults; a blueprint gets the model’s default, an empty list. Every login failed with Invalid grant_type for provider in authentik’s log, and the application showed a generic “Invalid credentials”. I spent an hour checking the secret, the redirect URI, the scopes and the bindings before I opened authentik’s log, where the answer was written. The field is now explicit:
grant_types: - authorization_code - refresh_tokenBlueprints were not the only thing that night that said “done” without doing it. An Ansible task in the Forgejo role reported changed after a delete that had failed, and that one I wrote myself: the delete and the echo that changed_when looked for were joined with ; instead of &&. From that night on, a step counts as done only after a check on the host.
The OIDC apps, one at a time
The six OIDC providers came up next to Authelia’s and stayed unused until each app was pointed at the new issuer.
All the providers use sub_mode: user_username, so sub carries the username instead of an opaque ID, and most apps find their user again. Tessera did not. It had stored Authelia’s opaque sub in app_user.subject, and after the switch every login failed with “Invalid credentials”. One UPDATE fixed it.
Forgejo got a second authentication source next to Authelia’s, and as long as both were active I could log in with either. After the first login through authentik, external_login_user had two rows pointing to the same user. Grimmory worked on the first try by luck: it still stores the old sub and issuer, but it matches users on preferred_username.
The sixth provider was for Seerr, and I had copied it from Authelia without checking. Seerr has no OIDC at all: its only logins are Plex, Jellyfin and local accounts. I removed the provider.
Prove Authelia is unused, then delete it
The repo said nothing used Authelia anymore. Before deleting it, I asked Authelia’s own database. The last successful authentication was my login to Grimmory at 02:58, right before it moved, and there is nothing after it. Then I listed every OIDC client that ever received a token:
SELECT client_id, max(requested_at), count(*) FROM oauth2_access_token_sessionGROUP BY client_id ORDER BY 2 DESC;Seerr is not in the result: its client never issued a token in the whole history of the instance. Two other clients belonged to services I had removed from the repo months before, and they were still registered in the IdP. Seerr’s client would have joined them.
Then the destructive steps, in this order: a full pg_dump of Authelia’s database and a tarball of its configuration, both in a folder restic backs up; DROP DATABASE; the ZFS dataset; the eleven keys in the vault. To roll back there is that dump and the role in git history. The TOTP registrations inside the dump can’t be decrypted anymore, because the encryption key was one of the deleted vault keys. That is on purpose: the second factors were set up again on authentik.
What came after
authentik does not stop brute force out of the box. It keeps a reputation score per login, -1 for a failure and +1 for a success, and nothing uses it until a policy does. The first layer is a fail2ban jail on authentik’s JSON log in journald, where the address field is client_ip (Authelia’s was remote_ip).
The second layer is a reputation policy, and its threshold reads backwards: the policy passes when the score is at or below the threshold, that is, when the IP looks bad. Bind it to the login flow as an access check and you get the opposite of what you want. Here it decides whether a deny stage runs. The threshold is -10 and not -5, because the policy has no exception for the LAN, and at -5 five wrong passwords in a row lock you out for a day. It also needs evaluate_on_plan: false: authentik caches flow plans, and in my test an IP at -15 got through on a plan built while it was still clean.
The default session_duration is seconds=0, and it does not mean “expire now”. It ties the session to the browser’s, so the cookie lives until the browser is closed, and many browsers never are. Sessions now end after seven days, including the one created at the end of a password recovery.
A month later, I’m happy with authentik. Two services still ask for a second login behind the forward-auth. Seerr has no OIDC and does not read identity headers, so the second login is the only way it can work. Questarr has its own JWT, and I haven’t found OIDC support in it.