Skip to content
Panda Valley Technology

Lab walkthrough

Self-Hosted Foundation, Part 2: Authentik

· 11 min read

Contents

The Overview set up independent Compose stacks on one shared Docker network, and Part 1 made Nginx Proxy Manager (NPM) the only way in. Every hostname gets a valid certificate, but nothing controls who gets through.

This part adds Authentik as the identity provider for everything behind the proxy. It ends with two ways to connect an app, and a rule for choosing between them. The files for this part are in authentik/ and npm/snippets/ in the repo.

Why an identity provider at all

Without one, every app keeps its own user list. Five services means five passwords to manage, five places to turn on MFA (if they support it), and five accounts to remember to disable when someone leaves. Some apps have no login at all and rely on “nobody knows the URL”.

An identity provider moves all of that to one place: one account, one MFA setup, one switch to turn someone off. Authentik is a good fit for self-hosting because it does both things this stack needs:

  • OIDC and SAML for apps that support SSO natively.
  • Forward auth through a reverse proxy, for apps that don’t.

The Compose stack

Authentik runs as three containers: the server (web UI, API, and the embedded outpost that handles forward auth), the worker (background tasks) and PostgreSQL. Redis used to be a fourth, but it hasn’t been needed since 2025.10.

The file below is based on the official compose file, adapted to the conventions from the Overview:

# Postgres sits on a private network only. Server and worker also join the
# shared network so NPM can reach authentik-server:9000 (no host ports).
x-authentik: &authentik
  image: ${AUTHENTIK_IMAGE:-ghcr.io/goauthentik/server}:${AUTHENTIK_TAG:-2026.8.3}
  restart: unless-stopped
  shm_size: 512mb
  depends_on:
    postgresql:
      condition: service_healthy
  env_file:
    - path: .env
      required: false
  environment:
    AUTHENTIK_POSTGRESQL__HOST: authentik-postgresql
    AUTHENTIK_POSTGRESQL__NAME: ${PG_DB:-authentik}
    AUTHENTIK_POSTGRESQL__USER: ${PG_USER:-authentik}
    AUTHENTIK_POSTGRESQL__PASSWORD: ${PG_PASS:?database password required}
    AUTHENTIK_SECRET_KEY: ${AUTHENTIK_SECRET_KEY:?secret key required}
    TZ: ${TZ:-Etc/UTC}
  networks:
    - default
    - backend

services:
  postgresql:
    image: docker.io/library/postgres:16-alpine
    container_name: authentik-postgresql
    restart: unless-stopped
    environment:
      POSTGRES_DB: ${PG_DB:-authentik}
      POSTGRES_USER: ${PG_USER:-authentik}
      POSTGRES_PASSWORD: ${PG_PASS:?database password required}
      TZ: ${TZ:-Etc/UTC}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -d $${POSTGRES_DB} -U $${POSTGRES_USER}"]
      interval: 30s
      timeout: 5s
      retries: 5
      start_period: 20s
    volumes:
      - ./data/postgres:/var/lib/postgresql/data
    networks:
      - backend

  server:
    <<: *authentik
    container_name: authentik-server
    command: server
    volumes:
      - ./data/authentik:/data
      - ./data/templates:/templates

  worker:
    <<: *authentik
    container_name: authentik-worker
    command: worker
    # Outposts managed by Authentik would need the Docker socket mounted here
    # (and user: root); not needed yet.
    volumes:
      - ./data/authentik:/data
      - ./data/certs:/certs
      - ./data/templates:/templates

networks:
  default:
    name: ${APP_NETWORK:-apps}
    external: true
  backend:

The choices that differ from the upstream file, and why:

  • One anchor for server and worker. They run the same image with the same settings. The x-authentik block with a YAML anchor keeps the two in sync, so an upgrade or a new setting can’t land on one and miss the other.
  • Two networks. Postgres is only on backend. The server and worker join both backend and the shared network. NPM can reach authentik-server:9000, and nothing on the shared network can reach the database.
  • No host ports. Upstream publishes 9000 and 9443. Here Authentik is only reachable through NPM, like every other service.
  • No Docker socket on the worker. Upstream mounts /var/run/docker.sock into the worker and runs it as root, so Authentik can deploy extra outposts as containers. Access to the Docker socket is effectively root on the host. The embedded outpost inside the server covers forward auth for this setup, so the socket stays out until there’s a real need for it.
  • env_file with required: false. Any extra AUTHENTIK_* setting you put in .env (email, for example) reaches the containers without editing the compose file. The path/required syntax needs Compose v2.24 or newer.

Secrets and first start

cd authentik
cp .env.example .env
sed -i "s|^PG_PASS=.*|PG_PASS=$(openssl rand -base64 36 | tr -d '\n')|" .env
sed -i "s|^AUTHENTIK_SECRET_KEY=.*|AUTHENTIK_SECRET_KEY=$(openssl rand -base64 60 | tr -d '\n')|" .env
chmod 600 .env
direnv allow

Treat both values as permanent:

  • PG_PASS is only read by Postgres the first time it initializes the data directory. Changing it in .env afterwards doesn’t change the database password. Authentik then connects with the new one and fails.
  • AUTHENTIK_SECRET_KEY signs sessions and tokens. Changing it logs everyone out and invalidates existing tokens.

Create the data directories as your own user before the first start. The server runs as UID 1000, and if Docker creates the bind-mount directories for you, they’re owned by root and the server can’t write to them:

mkdir -p data/authentik data/templates data/certs
docker compose up -d

The first start runs database migrations. Give both containers a few minutes to become healthy before assuming something is wrong.

Keeping the data somewhere else

To keep Authentik’s data on a NAS, do what Part 1 does for NPM: named NFS volumes in compose.override.yaml that replace the bind mounts by container path. The general NFS caveats there apply here too. Authentik has more of them, and the server and worker share some:

services:
  postgresql:
    volumes:
      - authentik-postgres:/var/lib/postgresql/data
  server:
    volumes:
      - authentik-data:/data
      - authentik-templates:/templates
  worker:
    volumes:
      - authentik-data:/data
      - authentik-certs:/certs
      - authentik-templates:/templates

# volumes: one NFS-backed entry per name above, defined as in Part 1

The full version, with all four volume definitions, is in authentik/compose.override.example.yaml.

Override only the entries you want to move. Two caveats are specific to this stack:

  • Postgres on NFS is the risky one. Databases depend on file locking and fsync behaving exactly as on local disk. If you put it on NFS, use NFSv4 with hard mounts. The safer option is to keep Postgres local and move only the media directories, relying on regular dumps (see Backups) to get the database off the host.
  • Two different users write to the exports. Authentik runs as UID 1000. Postgres on the Alpine image runs as UID 70. Each export has to be owned by, or writable for, the matching UID.

Putting it behind NPM

In NPM, add a proxy host:

  • Domain: auth.example.com
  • Forward: http, authentik-server, 9000
  • Websockets support: on
  • SSL: the wildcard certificate from Part 1, Force SSL on

Then open https://auth.example.com/if/flow/initial-setup/ and set the password for the default admin account, akadmin.

Hardening after first login

A fresh install works, but a few defaults are worth changing right away:

  • Use a personal admin account. Create your own user, add it to the authentik Admins group, and set up MFA on it. Keep akadmin with a long random password as a break-glass account, or deactivate it.
  • Give akadmin a real email address (Directory → Users → akadmin → Edit), so password recovery goes somewhere you read.
  • Set the embedded outpost’s public URL. Applications → Outposts → authentik Embedded Outpost → Advanced settings, and set authentik_host to https://auth.example.com. Forward-auth login redirects use this URL. If it’s wrong, users get sent to an internal address their browser can’t reach.
  • Bind a group to every application. An application with no policy, group or user bindings is open to every Authentik user. Create groups such as infra-admins and bind them explicitly. The default is open, so restricting access is something you have to remember to do for every app.

Native OIDC or forward auth

There are two ways to put an app behind Authentik, and the choice is per app.

Native OIDC/SAML. The app itself is an OIDC client. It redirects to Authentik for login, gets back an identity, and manages its own sessions and permissions. This needs the app to support it, and many do (Gitea, Grafana, Nextcloud, Portainer, …).

Forward auth. The app knows nothing about Authentik. NPM asks Authentik “is this request allowed?” before passing it on. That works for any HTTP app, including ones with no login at all.

sequenceDiagram
  participant B as Browser
  participant N as NPM
  participant O as Authentik outpost
  participant A as App
  B->>N: GET app.example.com/
  N->>O: auth_request /outpost.goauthentik.io/auth/nginx
  O-->>N: 401 (no session)
  N-->>B: 302 to /outpost.goauthentik.io/start
  B->>O: login flow at auth.example.com
  O-->>B: session cookie, redirect back
  B->>N: GET app.example.com/ (with cookie)
  N->>O: auth_request
  O-->>N: 200 + X-authentik-* headers
  N->>A: proxied request + user headers
  A-->>B: response

The rule: if the app supports OIDC or SAML, use it, and don’t also put it behind forward auth. Forward auth assumes every client is a browser that can follow a login redirect and carry a cookie. Plenty of clients aren’t:

  • git clone https://… sends Basic auth and gets a redirect to a login page.
  • API clients and CLI tools with personal access tokens get the same.
  • Webhooks from other services can’t log in at all.

An app with native SSO handles all three properly, because it can accept a token or password for machine clients and use OIDC for people. Forward auth is for the rest: dashboards, admin UIs, and tools that have no login of their own.

Forward auth in NPM

Authentik’s nginx guide gives a block of config to paste into each protected host. Pasting the same 40 lines into every host means fixing a bug in 40 places later. Instead, the config lives in two snippet files in npm/snippets/, which Part 1 mounted read-only at /snippets.

authentik-server.conf goes at server level. It adds the outpost endpoints and the sign-in redirect:

# Authentik's headers can exceed nginx's default buffer size.
proxy_buffers 8 16k;
proxy_buffer_size 32k;

port_in_redirect off;

# Outpost endpoints must be reachable without authentication.
location /outpost.goauthentik.io {
    # Embedded outpost runs inside authentik-server. The hostname must be a
    # variable so nginx re-resolves it at runtime; a literal name is resolved
    # once at startup (and shadows other hosts proxying to the same name),
    # which breaks when the container is recreated with a new IP.
    set $authentik_upstream authentik-server;
    proxy_pass              http://$authentik_upstream:9000;
    proxy_set_header        Host $host;
    proxy_set_header        X-Original-URL $scheme://$http_host$request_uri;
    add_header              Set-Cookie $auth_cookie;
    auth_request_set        $auth_cookie $upstream_http_set_cookie;
    proxy_pass_request_body off;
    proxy_set_header        Content-Length "";
}

# A 401 from the auth endpoint starts the SSO flow.
location @goauthentik_proxy_signin {
    internal;
    add_header Set-Cookie $auth_cookie;
    return 302 /outpost.goauthentik.io/start?rd=$scheme://$http_host$request_uri;
}

Note the set $authentik_upstream line. It’s the variable-upstream rule from Part 1 applied to the one upstream every protected host shares. With a literal proxy_pass http://authentik-server:9000, recreating Authentik during an upgrade would break forward auth on every protected host at once, and the regular auth.example.com host along with them.

authentik-location.conf goes inside location /. It checks every request against the outpost and passes the user’s identity to the app:

auth_request     /outpost.goauthentik.io/auth/nginx;
error_page       401 = @goauthentik_proxy_signin;
auth_request_set $auth_cookie $upstream_http_set_cookie;
add_header       Set-Cookie $auth_cookie;

# Pass the authenticated user to the upstream.
auth_request_set $authentik_username $upstream_http_x_authentik_username;
auth_request_set $authentik_groups $upstream_http_x_authentik_groups;
auth_request_set $authentik_email $upstream_http_x_authentik_email;
auth_request_set $authentik_name $upstream_http_x_authentik_name;
auth_request_set $authentik_uid $upstream_http_x_authentik_uid;
proxy_set_header X-authentik-username $authentik_username;
proxy_set_header X-authentik-groups $authentik_groups;
proxy_set_header X-authentik-email $authentik_email;
proxy_set_header X-authentik-name $authentik_name;
proxy_set_header X-authentik-uid $authentik_uid;

# NPM's websocket toggle doesn't apply to a custom location.
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $http_connection;
proxy_http_version 1.1;

include conf.d/include/proxy.conf;

The last line reuses NPM’s own proxy.conf, which proxies to whatever forward scheme, host and port the proxy host has set in the UI. The snippet doesn’t need to know which app it’s protecting, so the same file works for every host.

Protecting an app

  1. In Authentik, create an application with a Proxy Provider in Forward auth (single application) mode. Set the external host to the app’s URL (https://app.example.com) and bind a group to the application.
  2. Add the application to the authentik Embedded Outpost.
  3. In NPM, create the proxy host as usual (forward host = the app’s container name), then paste this into its Advanced tab:
include /snippets/authentik-server.conf;

location / {
    include /snippets/authentik-location.conf;
}

Three lines per host, and every protected host runs the same reviewed config from Git.

A custom location / replaces NPM’s

If a host’s Advanced config defines location /, NPM doesn’t generate its own. That’s what makes the include above work, but it also means the Websockets and HSTS toggles in the UI stop applying to that host. The location snippet sets the websocket headers itself. If you rely on HSTS, add the header in the Advanced tab too.

After editing a snippet on disk, test and reload:

docker exec nginx-proxy-manager nginx -t
docker exec nginx-proxy-manager nginx -s reload

Living with it

Authentik is now a dependency

If Authentik is down, every forward-auth host is unreachable. That’s what you want from a security point of view, but make sure you can still get in to fix it:

  • NPM’s admin UI on 127.0.0.1:81 (from Part 1) doesn’t go through Authentik, even if its public proxy host does.
  • Apps using native OIDC should keep a local admin account that works without SSO.
  • The break-glass akadmin account gets you into Authentik itself if your personal account or its MFA device is the problem.

Backups

docker exec authentik-postgresql pg_dump -U authentik authentik > authentik-$(date +%F).sql

The database dump isn’t enough on its own. Also back up data/authentik (uploaded media), data/templates, data/certs and .env. Without the same AUTHENTIK_SECRET_KEY, a restored database is only partly usable.

Upgrades

Bump AUTHENTIK_TAG in .env, take a database dump, read the release notes, then docker compose pull && docker compose up -d. Upgrade through each release in turn rather than skipping versions. Migrations assume the previous release’s schema.

Postgres is a separate case. A major version change (16 → 17) can’t be done by changing the image tag, because the new version won’t start on the old data directory. That’s a dump, a fresh data directory and a restore.

What’s next

That completes the foundation: one way in, valid certificates for every hostname, and one identity provider deciding who gets past the proxy. Every service added from here follows the same steps: a compose file on the shared network, a proxy host in NPM, and either native OIDC or the three-line forward-auth include.

The next series, Self-Hosted Services, puts real applications on top of it: Gitea connected to Authentik over OIDC, Jellyfin, Immich, and running more than one instance of the same app side by side.