Andrew Mercer
on this page

Authelia: SSO and 2FA for the Homelab

Authelia is an open-source authentication and authorization server that sits beside a reverse proxy and decides, for every request, whether the caller may reach the backend. It adds single sign-on and multi-factor authentication (TOTP, WebAuthn/passkeys, Duo push) to applications that have no login of their own, or whose login you'd rather not expose to the internet. Since 4.37 it is also an OpenID Connect 1.0 provider, so apps that speak OIDC (Grafana, GitLab, Portainer, Jellyfin via plugin, Proxmox, and many more) can log in against it directly.

This guide covers Authelia 4.39.x (the chart and image pin 4.39.18). It explains the model first, then walks through a production-shaped configuration, and finishes with two complete deployment paths:

  • Standalone container — a pinned, non-root Containerfile plus a Compose stack with Valkey for sessions and file-based secrets sourced from pass.
  • Kubernetes — a Helm chart that follows the same conventions as the rest of the lab's charts (Service named app on port 80, helm.sh/resource-policy: keep on the PVC, non-root UID 10001, Recreate strategy for SQLite).

The container, Compose and Helm files live in one repository, laid out like the other lab apps: container and Compose files at the root, the chart in helm/. The nginx templates belong to the reverse proxy's repository.

authelia/                       # the repo
├── Containerfile
├── .dockerignore
├── .gitlab-ci.yml
├── compose.yaml
├── compose.local.yaml
├── .env.example
├── .gitignore
├── Makefile
├── config/
│   ├── configuration.yml
│   └── users_database.yml
├── scripts/
│   └── gen-secrets.sh
└── helm/
    ├── Chart.yaml
    ├── Makefile
    ├── values.yaml
    ├── values.local.yaml.example
    └── templates/
        ├── _helpers.tpl
        ├── configmap.yaml
        ├── secret.yaml
        ├── users-secret.yaml
        ├── deployment.yaml
        ├── service.yaml
        ├── serviceaccount.yaml
        ├── pvc.yaml
        ├── ingress.yaml
        ├── traefik-middleware.yaml
        └── NOTES.txt

nginx-reverse-proxy/templates/  # lives in the reverse proxy's repo
├── auth.conf.template
├── grafana.conf.template
└── snippets/
    ├── authelia-location.conf.template
    └── authelia-authrequest.conf.template

1. What Authelia is (and isn't)

Authelia is deliberately small. It is a single Go binary with an embedded React portal, it needs no JVM and no database server (SQLite is fine), and it idles at roughly 30 MB of RAM. That's the main reason to pick it over the heavier options:

Authelia Authentik Keycloak
Footprint 1 binary, ~30 MB RAM Python + worker + PostgreSQL + Redis JVM, ~500 MB+ RAM, PostgreSQL
Configuration One YAML file (GitOps-friendly) Web UI / blueprints Web UI / realm export JSON
User store YAML file or LDAP Built-in DB, LDAP, SCIM Built-in DB, LDAP, federation
Forward-auth for proxies First-class Yes (outpost) Via oauth2-proxy
OIDC provider Yes (OpenID Certified) Yes Yes (the reference implementation)
SAML No Yes Yes
Admin UI / user self-registration No / No Yes / Yes Yes / Yes

Choose Authelia when you want a config-as-code gatekeeper in front of a reverse proxy with a handful of users. Choose Keycloak or Authentik when you need SAML, self-registration, a user-management UI, or identity federation.

Authelia is not a reverse proxy and never proxies application traffic. The proxy (nginx, Traefik, Caddy, HAProxy, Envoy) makes a small subrequest to Authelia and acts on the answer.


2. How forward-auth works

 Browser                    nginx                          Authelia                Backend
    │  GET https://grafana.example.com/                        │                       │
    │──────────────────────────▶│                              │                       │
    │                           │ subrequest (no body)         │                       │
    │                           │ GET /api/authz/auth-request  │                       │
    │                           │ X-Original-URL, -Method, XFF │                       │
    │                           │─────────────────────────────▶│                       │
    │                           │                              │ cookie? session?      │
    │                           │                              │ rule match? level?    │
    │                           │   401 + Location: https://auth.example.com/?rd=…     │
    │                           │◀─────────────────────────────│                       │
    │  302 → auth portal        │                              │                       │
    │◀──────────────────────────│                              │                       │
    │                                                                                  │
    │  … user logs in (password, then TOTP / WebAuthn) on auth.example.com, which      │
    │  sets the authelia_session cookie for .example.com and redirects back …          │
    │                                                                                  │
    │  GET https://grafana.example.com/  (cookie attached)                             │
    │──────────────────────────▶│─────────────────────────────▶│                       │
    │                           │   200 + Remote-User/Groups/Email/Name                │
    │                           │◀─────────────────────────────│                       │
    │                           │ proxy_pass with Remote-* headers ───────────────────▶│
    │◀──────────────────────────│◀────────────────────────────────────────────────────│

The pieces that make this work:

The session cookie is scoped to the parent domain. Logging in once at auth.example.com sets a cookie for example.com, which the browser sends to every sibling subdomain. That is the "single sign-on". It is also the main constraint: every protected app must live under a domain listed in session.cookies. Authelia refuses to scope a cookie to a public suffix (com, co.uk, duckdns.org…), so a bare dynamic-DNS name won't work as the root.

The authz endpoint answers one question per request. With the AuthRequest implementation (used by nginx and HAProxy) it returns 200 to allow, 401 to demand login (with a Location header already pointing at the portal and the correct rd= return URL), or 403 to deny. On 200 it also returns Remote-User, Remote-Groups, Remote-Email and Remote-Name, which the proxy can pass to the backend for header-based SSO.

The proxy must tell Authelia what the original request was. The subrequest goes to Authelia's own URL, so the proxy passes the real target in X-Original-URL and X-Original-Method (AuthRequest) or X-Forwarded-Proto/Host/URI/Method (ForwardAuth). It also passes the client IP in X-Forwarded-For, which network-based rules depend on.

Authelia 4.38+ ships four pre-defined authz endpoints, so you rarely need to configure them:

Endpoint Implementation Use with
/api/authz/auth-request AuthRequest nginx auth_request, HAProxy
/api/authz/forward-auth ForwardAuth Traefik, Caddy, Skipper
/api/authz/ext-authz ExtAuthz Envoy / Istio
/api/verify Legacy Old configs only — deprecated

3. Core concepts

3.1 Authentication levels and policies

Every access-control rule resolves to one of four policies:

Policy Meaning
bypass No authentication at all. Use for the portal itself, health checks, public assets.
one_factor Username + password (or a passkey — see below).
two_factor First factor plus TOTP, WebAuthn, or Duo.
deny Always 403, even for authenticated users.

A session carries the highest level it has reached. A user who logged in with one factor and then hits a two_factor resource is sent back to the portal only for the second factor ("step-up"). In 4.39, passkey login counts as one factor by default; the user still enters a password to reach two-factor.

3.2 How rules are matched

access_control.rules is an ordered list and the first rule that matches wins. If nothing matches, default_policy applies (use deny). A rule matches when all of its criteria match:

Criterion Example Notes
domain '*.example.com', ['a.example.com','b.example.com'] Wildcards match one or more labels.
domain_regex '^(?P<User>\w+)\.example\.com$' Named groups User/Group can bind to the subject.
resources ['^/api/.*$'] Regex on path + query.
methods ['GET','HEAD'] Handy for read-only public access.
networks ['internal'], ['192.168.1.0/24'] Names come from definitions.network.
subject 'group:admins', 'user:andrew', [['group:a','group:b']] Outer list = OR, inner list = AND.
query [[{key: 'secret', operator: 'present'}]] Rarely needed.

The ordering rule has a trap worth calling out. Consider:

rules:
  - domain: 'grafana.example.com'
    subject: 'group:admins'
    policy: 'two_factor'
  - domain: '*.example.com'
    policy: 'two_factor'

A non-admin doesn't match the first rule (wrong subject), so evaluation falls through to the wildcard, which lets them in. A subject-restricted rule must be followed by an explicit deny for the same domain. The shipped configs do exactly this.

A related subtlety: when the user is not logged in yet, Authelia doesn't know their groups, so a rule with subject can't match. Authelia handles this by requiring login first if any later rule could match with a subject; you don't have to model it, but it explains why an anonymous request to a subject-gated domain redirects to login rather than getting a 403.

Test rules without clicking through a browser:

authelia access-control check-policy --config /config/configuration.yml \
  --url https://grafana.example.com/ --username bob --groups users --ip 192.168.1.50

3.3 Second factors

Method Config Notes
TOTP totp: Leave algorithm: SHA1, digits: 6, period: 30 — most authenticator apps silently ignore anything else.
WebAuthn webauthn: Hardware keys, platform authenticators (Windows Hello, Touch ID). Requires HTTPS on a real hostname.
Passkeys webauthn.enable_passkey_login 4.39+. Usernameless sign-in; counts as one factor by default.
Duo duo_api: Push notifications via Duo's API.

Enrollment of TOTP/WebAuthn requires identity verification, which sends a one-time code or link through the notifier (email, or a file for testing).

3.4 Moving parts and where state lives

Component Purpose Options State
Authentication backend Checks usernames/passwords, supplies groups and email file (YAML), ldap (lldap, OpenLDAP, AD, FreeIPA, GLAuth) File backend: the YAML file
Storage TOTP secrets, WebAuthn credentials, user preferences, auth logs, bans, OIDC consent/tokens local (SQLite), postgres, mysql Encrypted with storage.encryption_key
Session provider Active sessions in-memory (default), Redis/Valkey (with Sentinel) Memory is lost on restart
Notifier Delivers verification and reset messages filesystem, smtp —
Regulation Bans after repeated failures user, ip (4.39+) In storage

The one piece of state you must not lose is storage.encryption_key. Every TOTP secret and WebAuthn credential in the database is encrypted with it. Lose the key and every user has to re-enroll their second factor. Both deployment paths below keep it in pass.

3.5 Secrets and configuration sources

Authelia reads configuration from three places, merged in this order:

  1. The YAML file(s) given with --config (several files, or a directory, are merged).
  2. Environment variables. Any key can be set as AUTHELIA_<PATH>, with . becoming _. For example session.redis.password becomes AUTHELIA_SESSION_REDIS_PASSWORD.
  3. The _FILE variant of any secret variable, e.g. AUTHELIA_SESSION_SECRET_FILE=/run/secrets/session_secret, which reads the value from a file. Always use this for secrets. It keeps them out of docker inspect, process listings and the config file.

Two gotchas come with the environment variable mechanism:

  • Authelia rejects unknown AUTHELIA_* variables and refuses to start. So never pass a Compose .env containing something like AUTHELIA_VERSION= into the container with env_file. The Compose stack below interpolates .env but passes only an explicit environment: block. In Kubernetes, a Service named authelia injects AUTHELIA_PORT=tcp://… into every pod in the namespace through service links. The chart names its Service app and sets enableServiceLinks: false anyway.
  • Template filter. With X_AUTHELIA_CONFIG_FILTERS=template, the YAML is first run through a Go template engine that provides env, secret, mindent, msquote and others. That's how the Compose config uses {{ env "DOMAIN" }} so no domain is committed, and how an OIDC private key is inlined from a file. The engine processes the whole file, comments included, so a commented-out {{ … }} still gets evaluated.

The secrets you need:

Secret Env var (_FILE) Generate
Reset-password JWT secret AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE 64 random alphanumerics
Session secret AUTHELIA_SESSION_SECRET_FILE 64 random alphanumerics
Storage encryption key AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE 64 random alphanumerics (≥ 20 chars required). Back it up.
Redis password AUTHELIA_SESSION_REDIS_PASSWORD_FILE If Redis/Valkey is used
PostgreSQL password AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE If storage.postgres
SMTP password AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE If notifier.smtp
LDAP bind password AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE If authentication_backend.ldap
OIDC HMAC secret AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE If OIDC is enabled
OIDC JWKS private key via template secret function RSA 2048/4096 PEM

Authelia can generate these itself:

docker run --rm docker.io/authelia/authelia:4.39.18 \
  authelia crypto rand --length 64 --charset alphanumeric

docker run --rm docker.io/authelia/authelia:4.39.18 \
  authelia crypto pair rsa generate --bits 4096 --directory /tmp   # (mount a volume to keep it)

3.6 Password hashing

The file backend stores password hashes, never plaintext. Generate them with the real binary:

docker run --rm -it docker.io/authelia/authelia:4.39.18 \
  authelia crypto hash generate argon2
# Enter Password: ********
# Confirm Password: ********
# Digest: $argon2id$v=19$m=65536,t=3,p=4$…

Authelia verifies argon2, scrypt, pbkdf2, bcrypt and sha2crypt hashes, but writes new ones (on password reset/change) with whatever authentication_backend.file.password.algorithm says. Argon2id with m=65536 uses 64 MiB per verification. Size memory limits with that in mind, especially if several people log in at once.


4. The configuration, annotated

This is the standalone configuration used by the Compose stack. The Helm chart renders the same structure from values.yaml (section 7).

config/configuration.yml

# yamllint disable rule:comments-indentation
---
###############################################################################
# Authelia configuration — standalone (compose) deployment
#
# Rendered through Authelia's template filter (X_AUTHELIA_CONFIG_FILTERS=template)
# so {{ env "DOMAIN" }} is substituted at startup and no domain is committed.
#
# Secrets are NOT in this file. They arrive via AUTHELIA_*_FILE environment
# variables (see compose.yaml). Anything you can set here you can also set
# with an env var: section.sub_key -> AUTHELIA_SECTION_SUB_KEY.
#
# Validate before (re)starting:
#   docker compose run --rm authelia validate-config --config /config/configuration.yml
###############################################################################

theme: 'auto'            # light | dark | grey | oled | auto

server:
  address: 'tcp://:9091/'
  # Authz endpoints are pre-defined with sensible defaults:
  #   /api/authz/auth-request  -> NGINX auth_request, HAProxy
  #   /api/authz/forward-auth  -> Traefik, Caddy, Skipper
  #   /api/authz/ext-authz     -> Envoy
  #   /api/verify              -> legacy (deprecated, avoid)
  # Only override `endpoints.authz` if you need extra authn strategies
  # (e.g. Basic auth for API clients).
  buffers:
    read: 4096
    write: 4096

log:
  level: 'info'          # trace | debug | info | warn | error
  format: 'text'         # json is nicer for Loki/Vector pipelines

telemetry:
  metrics:
    enabled: false
    address: 'tcp://:9959/metrics'

totp:
  issuer: '{{ env "DOMAIN" }}'
  algorithm: 'SHA1'      # SHA1 is what every authenticator app actually supports
  digits: 6
  period: 30
  skew: 1

webauthn:
  display_name: 'Authelia'
  attestation_conveyance_preference: 'indirect'
  timeout: '60 seconds'
  # 4.39+: sign in with a passkey (still counts as one factor unless the
  # user also enters their password).
  enable_passkey_login: true

identity_validation:
  reset_password:
    # jwt_secret comes from AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
    jwt_lifespan: '5 minutes'

authentication_backend:
  # The users file is bind-mounted read-only, so Authelia cannot write new
  # hashes. Password reset/change must therefore be disabled — manage users
  # by editing users_database.yml. To allow self-service, move the file onto
  # the /data volume (read-write) and flip both of these.
  password_reset:
    disable: true
  password_change:
    disable: true

  file:
    path: '/config/users_database.yml'
    watch: true          # reload on edit, no restart required
    search:
      email: true
      case_insensitive: true
    password:
      algorithm: 'argon2'
      argon2:
        variant: 'argon2id'
        iterations: 3
        memory: 65536
        parallelism: 4
        key_length: 32
        salt_length: 16

  # --- LDAP alternative (e.g. lldap / OpenLDAP / AD) --------------------------
  # Remove the `file:` block above and use:
  # ldap:
  #   implementation: 'lldap'     # custom | activedirectory | freeipa | lldap | glauth ...
  #   address: 'ldap://lldap:3890'
  #   base_dn: 'dc=example,dc=com'
  #   user: 'uid=authelia,ou=people,dc=example,dc=com'
  #   # password via AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE

password_policy:
  zxcvbn:
    enabled: true
    min_score: 3

definitions:
  network:
    internal:
      - '10.0.0.0/8'
      - '172.16.0.0/12'
      - '192.168.0.0/16'

access_control:
  # Anything not matched below is denied. Rules are evaluated top-down and
  # the FIRST match wins, so put specific rules before broad ones.
  default_policy: 'deny'

  rules:
    # The portal itself must never be protected by Authelia.
    - domain: 'auth.{{ env "DOMAIN" }}'
      policy: 'bypass'

    # Health/metrics endpoints of apps that monitoring scrapes without a session.
    - domain: 'grafana.{{ env "DOMAIN" }}'
      resources:
        - '^/api/health$'
      policy: 'bypass'

    # Admin-only services, two factors required.
    - domain:
        - 'grafana.{{ env "DOMAIN" }}'
        - 'kibana.{{ env "DOMAIN" }}'
        - 'cockpit.{{ env "DOMAIN" }}'
      subject: 'group:admins'
      policy: 'two_factor'

    # Without this, non-admins would fall through to the wildcard catch-all
    # below and get in. A subject-restricted rule needs an explicit deny after it.
    - domain:
        - 'grafana.{{ env "DOMAIN" }}'
        - 'kibana.{{ env "DOMAIN" }}'
        - 'cockpit.{{ env "DOMAIN" }}'
      policy: 'deny'

    # Media is fine with one factor from inside the LAN...
    - domain: 'jellyfin.{{ env "DOMAIN" }}'
      networks:
        - 'internal'
      policy: 'one_factor'

    # ...but needs two from anywhere else.
    - domain: 'jellyfin.{{ env "DOMAIN" }}'
      policy: 'two_factor'

    # Catch-all for everything else under the domain: any authenticated user, 2FA.
    - domain: '*.{{ env "DOMAIN" }}'
      policy: 'two_factor'

session:
  # secret comes from AUTHELIA_SESSION_SECRET_FILE
  name: 'authelia_session'
  same_site: 'lax'
  inactivity: '1 hour'
  expiration: '12 hours'
  remember_me: '1 month'
  cookies:
    - domain: '{{ env "DOMAIN" }}'
      authelia_url: 'https://auth.{{ env "DOMAIN" }}'
      default_redirection_url: 'https://home.{{ env "DOMAIN" }}'
  redis:
    host: 'authelia-redis'
    port: 6379
    database_index: 0
    # password comes from AUTHELIA_SESSION_REDIS_PASSWORD_FILE

regulation:
  modes:
    - 'user'             # 4.39+: add 'ip' to also ban source addresses
  max_retries: 3
  find_time: '2 minutes'
  ban_time: '5 minutes'

storage:
  # encryption_key comes from AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE.
  # LOSING THIS KEY MEANS EVERY USER RE-ENROLLS TOTP/WEBAUTHN. Back it up.
  local:
    path: '/data/db.sqlite3'

  # --- PostgreSQL alternative (e.g. the bare-metal shared instance) ----------
  # postgres:
  #   address: 'tcp://postgres.lab.internal:5432'
  #   database: 'authelia'
  #   schema: 'public'
  #   username: 'authelia'
  #   # password via AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE
  #   timeout: '5 seconds'

notifier:
  disable_startup_check: false
  # Filesystem notifier: verification/reset links are written here instead of
  # emailed. Fine for first-run; switch to SMTP before enrolling real users.
  filesystem:
    filename: '/data/notification.txt'

  # --- SMTP via the lab Postfix relay ----------------------------------------
  # smtp:
  #   address: 'submission://postfix:587'   # smtp:// (25), submission:// (587), submissions:// (465)
  #   username: 'authelia'
  #   # password via AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE
  #   sender: 'Authelia <authelia@{{ env "DOMAIN" }}>'
  #   subject: '[Authelia] {title}'
  #   tls:
  #     server_name: 'postfix'
  #     skip_verify: false

# --- OpenID Connect 1.0 provider (optional) -----------------------------------
# Uncomment together with the oidc_* secrets in compose.yaml. See the docs
# section "Authelia as an OpenID Connect provider".
# identity_providers:
#   oidc:
#     # hmac_secret via AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
#     jwks:
#       - key_id: 'main'
#         algorithm: 'RS256'
#         use: 'sig'
#         # The template filter runs over comments too, so the expression is
#         # written without its braces here. When uncommenting, wrap it in double curly braces:
#         #   secret "/run/secrets/oidc_jwks_key" | mindent 10 "|" | msquote
#         key: REPLACE_WITH_TEMPLATE_EXPRESSION
#     cors:
#       endpoints: ['authorization', 'token', 'revocation', 'introspection', 'userinfo']
#       allowed_origins_from_client_redirect_uris: true
#     clients:
#       - client_id: 'grafana'
#         client_name: 'Grafana'
#         # `authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72`
#         client_secret: '$pbkdf2-sha512$310000$REPLACE_ME'
#         public: false
#         authorization_policy: 'two_factor'
#         require_pkce: true
#         pkce_challenge_method: 'S256'
#         redirect_uris:
#           - 'https://grafana.{{ env "DOMAIN" }}/login/generic_oauth'
#         scopes: ['openid', 'profile', 'groups', 'email']
#         response_types: ['code']
#         grant_types: ['authorization_code']
#         userinfo_signed_response_alg: 'none'
#         token_endpoint_auth_method: 'client_secret_basic'
...

And the users database. The placeholder hash is sha512crypt of changeme, so the stack boots on first run. Replace it before exposing anything:

config/users_database.yml

---
###############################################################################
# Authelia file authentication backend.
#
# Generate hashes with the Authelia binary (never paste plaintext here):
#   make hash-password
#   # or: docker run --rm docker.io/authelia/authelia:4.39.18 \
#   #       authelia crypto hash generate argon2 --password 'S3cret!'
#
# The placeholder hash below is sha512crypt of the string "changeme" so the
# file validates on first boot. REPLACE IT before exposing the portal.
###############################################################################
users:
  andrew:
    disabled: false
    displayname: 'Andrew'
    password: '$6$Zx8qLm2vRt4wYb7n$n6/B6j8/jSVooRv.vJAfTSPXb/RMN5cJk.4tCJotOv66YM8GwcHnCSCReJKz14l/GnbQ6PHL6VNzr56VOMWBU.'
    email: '[email protected]'
    groups:
      - 'admins'
      - 'users'

  # guest:
  #   disabled: false
  #   displayname: 'Guest'
  #   password: '$argon2id$v=19$m=65536,t=3,p=4$...'
  #   email: '[email protected]'
  #   groups:
  #     - 'users'
...

A few decisions in that file deserve explanation.

Password reset and change are disabled. The users file is bind-mounted read-only, so Authelia can't write a new hash. If you want self-service password reset, the file must be writable by UID 10001 (move it into the /data volume) and an SMTP notifier must be configured. With a handful of users, editing the file (hot-reloaded thanks to watch: true) is simpler and removes an attack surface.

Sessions go to Valkey. In-memory sessions are lost on every restart or image update, which logs everyone out. A tiny Valkey container fixes that for a few MB of RAM.

SQLite storage. For a single instance it is the right choice. Switch to the shared PostgreSQL server only if you want Authelia's data in the same backup regime as everything else, or plan to run more than one replica.

Filesystem notifier. It's fine for first-run testing, and enrollment codes appear in /data/notification.txt. Switch to SMTP (the lab's Postfix relay) before real users enroll, because the file notifier means anyone with shell access to the host can complete another user's enrollment.

Validate after every change:

make validate
# → docker compose run --rm --no-deps authelia authelia validate-config --config /config/configuration.yml

5. Deployment A: standalone container

5.1 The Containerfile

The upstream image is already good. Since 4.39 it is a chisel-built glibc image with no package manager, rebuilt daily. The reasons to wrap it anyway are operational:

  • Pinning in your own registry. The version you run changes only when you bump AUTHELIA_VERSION and CI rebuilds.
  • UID 10001 baked in, matching every other image in the lab, so volumes and PVCs have predictable ownership.
  • Pre-created directories. /config, /data and /secrets already exist with the right owner. Because the base image has no shell tooling for chown, ownership comes from COPY --chown out of a BusyBox helper stage.
  • A healthcheck fix for non-root. Authelia writes /app/.healthcheck.env at startup, and upstream's healthcheck script reads it. /app is root-owned, so a non-root process can't create that file. Pre-creating it owned by 10001 keeps the inherited HEALTHCHECK working.

Containerfile

# syntax=docker/dockerfile:1
#
# Authelia — thin, pinned, non-root layer over the official image.
#
# Why wrap the upstream image at all?
#   * Pin an exact version in *your* registry so an upstream retag can
#     never silently change what the lab runs.
#   * Bake in a non-root UID/GID (10001) to match every other image in the
#     lab, so bind mounts and PVCs are owned consistently.
#   * Pre-create /config, /data and /secrets with the right ownership. The
#     upstream image (4.39+) is a chisel-built glibc image with no package
#     manager, so ownership comes from COPY --chown out of a helper stage
#     rather than RUN chown.
#
# Build:
#   podman build -f Containerfile \
#     --build-arg AUTHELIA_VERSION=4.39.18 \
#     -t registry.gitlab.com/andrewmercer-org/authelia:4.39.18 .
#   (docker buildx build works identically)

ARG AUTHELIA_VERSION=4.39.18

# --- stage 1: directory skeleton ---------------------------------------------
FROM docker.io/library/busybox:1.37 AS skel
RUN mkdir -p /skel/config /skel/data /skel/secrets \
 && chmod 0750 /skel/config /skel/data \
 && chmod 0700 /skel/secrets \
 && : > /skel/.healthcheck.env

# --- stage 2: runtime ----------------------------------------------------------
FROM docker.io/authelia/authelia:${AUTHELIA_VERSION}

ARG AUTHELIA_VERSION
LABEL org.opencontainers.image.title="authelia" \
      org.opencontainers.image.description="Authelia SSO/2FA portal (pinned, non-root)" \
      org.opencontainers.image.version="${AUTHELIA_VERSION}" \
      org.opencontainers.image.source="https://gitlab.com/andrewmercer-org/authelia" \
      org.opencontainers.image.licenses="Apache-2.0"

COPY --from=skel --chown=10001:10001 /skel/config  /config
COPY --from=skel --chown=10001:10001 /skel/data    /data
COPY --from=skel --chown=10001:10001 /skel/secrets /secrets
# Upstream's HEALTHCHECK script reads /app/.healthcheck.env, which Authelia
# writes at startup. /app is root-owned, so a non-root process can't create
# it — pre-create it owned by 10001 so the inherited healthcheck keeps working.
COPY --from=skel --chown=10001:10001 /skel/.healthcheck.env /app/.healthcheck.env

# Enables the configuration template filter so configuration.yml can use
# {{ env "DOMAIN" }} and {{ secret "/path" }}. Harmless when unused.
ENV X_AUTHELIA_CONFIG_FILTERS=template \
    TZ=UTC

USER 10001:10001
WORKDIR /data

# 9091 = portal + authz endpoints, 9959 = Prometheus metrics (when enabled)
EXPOSE 9091 9959

# ENTRYPOINT and HEALTHCHECK are inherited from upstream. Upstream's default
# CMD already points at /config/configuration.yml; restated so the contract
# is explicit in this file.
CMD ["--config", "/config/configuration.yml"]

Build and push it in GitLab CI:

.gitlab-ci.yml

# Minimal build/push for the pinned image. Bump AUTHELIA_VERSION to upgrade.
variables:
  AUTHELIA_VERSION: "4.39.18"
  IMAGE: "$CI_REGISTRY_IMAGE"

build:
  stage: build
  image: quay.io/buildah/stable:latest
  script:
    - buildah login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" "$CI_REGISTRY"
    - buildah bud --build-arg AUTHELIA_VERSION="$AUTHELIA_VERSION"
        -f Containerfile -t "$IMAGE:$AUTHELIA_VERSION" -t "$IMAGE:latest" .
    - buildah push "$IMAGE:$AUTHELIA_VERSION"
    - buildah push "$IMAGE:latest"
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

To run the upstream image directly instead, set IMAGE=docker.io/authelia/authelia in .env and add user: "10001:10001" to the service. The data volume and healthcheck-file caveats then apply to you.

5.2 Compose stack

compose.yaml

# Authelia standalone deployment (Docker Compose / Podman Compose).
#
#   1. cp .env.example .env && $EDITOR .env
#   2. make secrets        # pulls/creates secrets in `pass`, writes ./secrets/*
#   3. make hash-password  # generate an argon2id hash for users_database.yml
#   4. make up
#
# NOTE: variables in .env are used for *compose interpolation only* — they are
# deliberately NOT passed into the container with env_file. Authelia treats
# every AUTHELIA_* environment variable as a configuration key and refuses to
# start on unknown ones, so only the explicit `environment:` block below
# reaches the process.

name: authelia

services:
  authelia:
    image: ${IMAGE:-registry.gitlab.com/andrewmercer-org/authelia}:${IMAGE_TAG:-4.39.18}
    container_name: authelia
    restart: unless-stopped
    environment:
      TZ: ${TZ:-UTC}
      # Consumed by {{ env "DOMAIN" }} in configuration.yml
      DOMAIN: ${DOMAIN:?set DOMAIN in .env}
      X_AUTHELIA_CONFIG_FILTERS: template
      # Secrets are passed as *_FILE paths, never as literal values.
      AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE: /run/secrets/jwt_secret
      AUTHELIA_SESSION_SECRET_FILE: /run/secrets/session_secret
      AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE: /run/secrets/storage_encryption_key
      AUTHELIA_SESSION_REDIS_PASSWORD_FILE: /run/secrets/redis_password
      # Uncomment together with the matching block in configuration.yml:
      # AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE: /run/secrets/postgres_password
      # AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE: /run/secrets/smtp_password
      # AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE: /run/secrets/oidc_hmac_secret
    secrets:
      - jwt_secret
      - session_secret
      - storage_encryption_key
      - redis_password
      # - postgres_password
      # - smtp_password
      # - oidc_hmac_secret
      # - oidc_jwks_key
    volumes:
      - ./config/configuration.yml:/config/configuration.yml:ro
      - ./config/users_database.yml:/config/users_database.yml:ro
      - authelia_data:/data
    depends_on:
      redis:
        condition: service_healthy
    security_opt:
      - no-new-privileges:true
    cap_drop:
      - ALL
    # Only needed if you want to hit the portal without the reverse proxy
    # (e.g. first-run testing). Keep it on loopback.
    # ports:
    #   - "127.0.0.1:9091:9091"
    networks:
      - internal_lab_net

  redis:
    image: docker.io/valkey/valkey:8-alpine
    container_name: authelia-redis
    restart: unless-stopped
    command:
      - sh
      - -c
      - exec valkey-server --requirepass "$$(cat /run/secrets/redis_password)" --save 300 1 --appendonly no
    secrets:
      - redis_password
    volumes:
      - redis_data:/data
    healthcheck:
      test: ["CMD-SHELL", "valkey-cli --no-auth-warning -a \"$$(cat /run/secrets/redis_password)\" ping | grep -q PONG"]
      interval: 10s
      timeout: 3s
      retries: 5
    security_opt:
      - no-new-privileges:true
    networks:
      - internal_lab_net

secrets:
  jwt_secret:
    file: ./secrets/jwt_secret
  session_secret:
    file: ./secrets/session_secret
  storage_encryption_key:
    file: ./secrets/storage_encryption_key
  redis_password:
    file: ./secrets/redis_password
  # postgres_password:
  #   file: ./secrets/postgres_password
  # smtp_password:
  #   file: ./secrets/smtp_password
  # oidc_hmac_secret:
  #   file: ./secrets/oidc_hmac_secret
  # oidc_jwks_key:
  #   file: ./secrets/oidc_jwks_key.pem

volumes:
  authelia_data:
  redis_data:

networks:
  internal_lab_net:
    external: true

.env.example

# Compose interpolation only — see the note at the top of compose.yaml.
# The real .env is gitignored; never commit your domain.

# Root domain the session cookie is scoped to. The portal lives at auth.${DOMAIN}.
DOMAIN=example.com

TZ=America/Toronto

# Build the image locally from ./Containerfile (no registry access
# needed). Remove both lines to pull the CI-built image from GitLab instead.
COMPOSE_FILE=compose.yaml:compose.local.yaml
IMAGE=localhost/authelia

# Authelia version. Also the build arg for the local build.
IMAGE_TAG=4.39.18

# pass(1) store + prefix used by scripts/gen-secrets.sh
# Leave unset to use pass(1)'s default store, or point at a split store.
# PASSWORD_STORE_DIR=/path/to/homelab-store
PASS_PREFIX=authelia

5.2.1 Building locally instead of pulling

The base compose.yaml pulls the CI-built image from the GitLab registry, which needs a docker login against that project. compose.local.yaml overrides the service to build the repo's Containerfile on the host instead, using the repo root as the build context. .dockerignore excludes everything except the Containerfile, so .env, secrets/ and helm/ never reach the build daemon. The build pulls only public base images, so no registry credentials are involved. .env.example enables it by default through COMPOSE_FILE, which docker compose reads from .env, so a plain docker compose up -d builds on first run.

compose.local.yaml

# Local-build override: builds the image from ./Containerfile on
# this host instead of pulling it from the GitLab registry. Nothing here needs
# registry credentials — the build only pulls public base images from Docker Hub.
#
# Enable it by adding these two lines to .env (docker compose reads
# COMPOSE_FILE from .env, so plain `docker compose up -d` picks it up):
#   COMPOSE_FILE=compose.yaml:compose.local.yaml
#   IMAGE=localhost/authelia

services:
  authelia:
    image: ${IMAGE:-localhost/authelia}:${IMAGE_TAG:-4.39.18}
    build:
      context: .
      dockerfile: Containerfile
      args:
        AUTHELIA_VERSION: ${IMAGE_TAG:-4.39.18}
    # Never try the registry for this service; build instead (layer cache
    # makes repeat builds near-instant).
    pull_policy: build

make hash-password and make rand use the public upstream image, so they work before anything is built. make pull passes --ignore-buildable so it doesn't try the registry for the local image.

Some points worth noting:

  • internal_lab_net is external, so the nginx reverse proxy reaches Authelia at http://authelia:9091 by container name, and no host port is published.
  • Secrets are Compose file secrets, mounted at /run/secrets/<name> and referenced through *_FILE variables. Nothing sensitive appears in docker inspect.
  • Valkey is the Redis-compatible fork and a drop-in replacement. Its password comes from the same secret file, read at container start.
  • Hardening: cap_drop: ALL and no-new-privileges. read_only: true is not set, because Authelia writes the healthcheck env file into /app at boot.

5.3 Secrets from pass

scripts/gen-secrets.sh treats pass as the source of truth. It reuses secrets that exist, generates and stores the ones that don't, and writes them to ./secrets/ for Compose. It's idempotent, so re-running it never rotates the storage key by accident.

scripts/gen-secrets.sh

#!/usr/bin/env bash
# Materialise Authelia secrets into ./secrets/ for docker compose.
#
# Source of truth is pass(1): each secret lives at $PASS_PREFIX/<name>.
# Missing ones are generated and inserted; existing ones are reused, so this
# is safe to re-run and the storage encryption key never silently rotates.
#
#   scripts/gen-secrets.sh            # core secrets
#   scripts/gen-secrets.sh --oidc     # + OIDC HMAC secret and RSA signing key
#   EXTRA="postgres_password smtp_password" scripts/gen-secrets.sh
#                                     # + secrets you created in pass yourself
#
# File permissions: ./secrets is 0700 (host users can't traverse it) and the
# files are 0444. A compose file-secret is a bind mount of the file itself, so
# the host directory permission doesn't apply inside the container, and the
# non-root container user (10001) can still read it.
set -euo pipefail
cd "$(dirname "$0")/.."

if [[ -f .env ]]; then set -a; . ./.env; set +a; fi
PREFIX="${PASS_PREFIX:-authelia}"
OUT=secrets
OIDC=false
[[ "${1:-}" == "--oidc" ]] && OIDC=true

umask 077
mkdir -p "$OUT"
chmod 0700 "$OUT"

have_pass() { command -v pass >/dev/null 2>&1; }

in_pass() { have_pass && pass show "$PREFIX/$1" >/dev/null 2>&1; }

random_string() {
  # alphanumeric only: avoids YAML/shell quoting surprises downstream
  ( set +o pipefail; LC_ALL=C tr -dc 'A-Za-z0-9' </dev/urandom | head -c "$1" )
}

write_file() { # name value
  printf '%s' "$2" > "$OUT/$1"   # no trailing newline
  chmod 0444 "$OUT/$1"
  echo "  wrote $OUT/$1"
}

secret() { # name length
  local name=$1 len=$2 val
  if in_pass "$name"; then
    val=$(pass show "$PREFIX/$name")
    echo "- $name: reused from pass"
  else
    val=$(random_string "$len")
    if have_pass; then
      printf '%s\n' "$val" | pass insert -m "$PREFIX/$name" >/dev/null
      echo "- $name: generated and stored in pass"
    else
      echo "- $name: generated (pass not found — back this up yourself!)"
    fi
  fi
  write_file "$name" "$val"
}

required() { # name — must already exist in pass
  if ! in_pass "$1"; then
    echo "ERROR: $PREFIX/$1 not found in pass — create it first" >&2
    exit 1
  fi
  write_file "$1" "$(pass show "$PREFIX/$1")"
}

echo "Authelia secrets (pass prefix: $PREFIX)"
secret jwt_secret             64
secret session_secret         64
secret storage_encryption_key 64
secret redis_password         48

if $OIDC; then
  secret oidc_hmac_secret 64
  if in_pass oidc_jwks_key; then
    key=$(pass show "$PREFIX/oidc_jwks_key")
    echo "- oidc_jwks_key: reused from pass"
  else
    key=$(openssl genpkey -algorithm RSA -pkeyopt rsa_keygen_bits:4096 2>/dev/null)
    if have_pass; then printf '%s\n' "$key" | pass insert -m "$PREFIX/oidc_jwks_key" >/dev/null; fi
    echo "- oidc_jwks_key: generated RSA-4096"
  fi
  printf '%s\n' "$key" > "$OUT/oidc_jwks_key.pem"
  chmod 0444 "$OUT/oidc_jwks_key.pem"
  echo "  wrote $OUT/oidc_jwks_key.pem"
fi

for name in ${EXTRA:-}; do required "$name"; done

echo "Done."

The permission scheme is worth understanding. ./secrets/ is 0700, so no other host user can traverse into it, while the files are 0444. A Compose file secret is a bind mount of the file itself. Inside the container, the host's directory permissions don't apply, so the non-root container user can read the file without it being owned by UID 10001 on the host.

5.4 Makefile

Makefile

# Authelia (compose) - Makefile

-include .env
IMAGE     ?= registry.gitlab.com/andrewmercer-org/authelia
IMAGE_TAG ?= 4.39.18
# Public upstream image for one-off CLI helpers (hash-password, rand), so they
# work before anything has been built or pulled.
UPSTREAM  ?= docker.io/authelia/authelia
COMPOSE   ?= docker compose
DATA_VOL  := authelia_authelia_data
BACKUPS   := backups

.PHONY: help secrets secrets-oidc hash-password rand validate build up down restart logs ps pull backup

.DEFAULT_GOAL := help

help: ## Display this help message
    @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} \
          /^[a-zA-Z_-]+:.*?##/ { printf "  \033[36m%-16s\033[0m %s\n", $$1, $$2 } \
          /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)

##@ Setup

secrets: ## Pull/generate core secrets from pass into ./secrets
    @scripts/gen-secrets.sh

secrets-oidc: ## As above, plus OIDC HMAC secret + RSA signing key
    @scripts/gen-secrets.sh --oidc

hash-password: ## Interactively hash a password (argon2id) for users_database.yml
    @docker run --rm -it $(UPSTREAM):$(IMAGE_TAG) authelia crypto hash generate argon2

rand: ## Print a random 64-char alphanumeric string
    @docker run --rm $(UPSTREAM):$(IMAGE_TAG) authelia crypto rand --length 64 --charset alphanumeric

validate: ## Validate configuration.yml with the real binary
    @$(COMPOSE) run --rm --no-deps authelia authelia validate-config --config /config/configuration.yml

##@ Lifecycle

build: ## Build the image locally (needs compose.local.yaml in COMPOSE_FILE)
    @$(COMPOSE) build authelia

up: ## Start (detached)
    @$(COMPOSE) up -d

down: ## Stop and remove containers (volumes are kept)
    @$(COMPOSE) down

restart: ## Restart Authelia only
    @$(COMPOSE) restart authelia

logs: ## Follow Authelia logs
    @$(COMPOSE) logs -f authelia

ps: ## Show service status
    @$(COMPOSE) ps

pull: ## Pull images (skips locally-built services)
    @$(COMPOSE) pull --ignore-buildable

##@ Maintenance

backup: ## Stop Authelia, tar the data volume (SQLite + notifications), start it again
    @mkdir -p $(BACKUPS)
    @$(COMPOSE) stop authelia
    @docker run --rm -v $(DATA_VOL):/data:ro -v $(CURDIR)/$(BACKUPS):/backup busybox:1.37 \
        tar czf /backup/authelia-data-$$(date +%Y%m%d-%H%M%S).tar.gz -C /data .
    @$(COMPOSE) start authelia
    @ls -1t $(BACKUPS) | head -1

5.5 First run

cd /opt/containers/authelia                # symlink → ~/Development/authelia
cp .env.example .env && $EDITOR .env        # set DOMAIN
make build                                  # local image from ./Containerfile (compose.local.yaml)
make secrets                                # pass → ./secrets/*
make hash-password                          # paste the digest into config/users_database.yml
make validate
make up && make logs

Then add the nginx vhost (section 6), browse to https://auth.<domain>, and log in. Register TOTP from the settings page. With the filesystem notifier, the verification code is here:

docker compose exec authelia cat /data/notification.txt

5.6 Running under systemd / Podman

If you'd rather run the container without Compose, podman generate systemd is deprecated in favor of Quadlet. A minimal ~/.config/containers/systemd/authelia.container looks like this:

[Unit]
Description=Authelia
After=network-online.target

[Container]
Image=registry.gitlab.com/andrewmercer-org/authelia:4.39.18
ContainerName=authelia
Network=internal_lab_net
Environment=DOMAIN=example.com
Environment=AUTHELIA_SESSION_SECRET_FILE=/run/secrets/session_secret
Environment=AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE=/run/secrets/storage_encryption_key
Environment=AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE=/run/secrets/jwt_secret
Environment=AUTHELIA_SESSION_REDIS_PASSWORD_FILE=/run/secrets/redis_password
Secret=authelia_session_secret,target=/run/secrets/session_secret
Secret=authelia_storage_encryption_key,target=/run/secrets/storage_encryption_key
Secret=authelia_jwt_secret,target=/run/secrets/jwt_secret
Secret=authelia_redis_password,target=/run/secrets/redis_password
Volume=/opt/containers/authelia/config:/config:ro,Z
Volume=authelia_data:/data
DropCapability=ALL
NoNewPrivileges=true
AutoUpdate=registry

[Install]
WantedBy=default.target

Load the secrets with pass show authelia/session_secret | podman secret create authelia_session_secret - (and so on), then run systemctl --user daemon-reload && systemctl --user start authelia.


6. Reverse proxy integration

6.1 nginx (auth_request)

nginx needs two snippets. They follow the lab's envsubst convention: files under templates/ ending in .conf.template are rendered into /etc/nginx/conf.d/ by the official image's entrypoint, and subdirectories are preserved. snippets/ ends up at /etc/nginx/conf.d/snippets/, which the default include conf.d/*.conf glob does not pick up, so the snippets are only loaded where you include them.

Define AUTHELIA_UPSTREAM in nginx's environment:

Deployment AUTHELIA_UPSTREAM
Compose (internal_lab_net) http://authelia:9091
k3s (chart in namespace authelia) http://app.authelia.svc.cluster.local

The internal subrequest location goes inside each protected server{} block:

nginx-reverse-proxy: templates/snippets/authelia-location.conf.template

# Internal subrequest target for auth_request. Include inside every protected
# server{} block (NOT inside a location).
#
# ${AUTHELIA_UPSTREAM} is substituted by the nginx image's envsubst step:
#   compose: http://authelia:9091
#   k3s:     http://app.authelia.svc.cluster.local
# Everything written as $lowercase is an nginx runtime variable and is left
# alone, because the official image only substitutes variables that are
# actually defined in the environment.

location /internal/authelia/authz {
    internal;
    proxy_pass ${AUTHELIA_UPSTREAM}/api/authz/auth-request;

    ## Headers the AuthRequest implementation requires
    proxy_set_header X-Original-Method $request_method;
    proxy_set_header X-Original-URL $scheme://$http_host$request_uri;
    proxy_set_header X-Forwarded-For $remote_addr;
    proxy_set_header Content-Length "";
    proxy_set_header Connection "";

    ## Basic proxy configuration
    proxy_pass_request_body off;
    proxy_next_upstream error timeout invalid_header http_500 http_502 http_503;
    proxy_redirect http:// $scheme://;
    proxy_http_version 1.1;
    proxy_cache_bypass $cookie_session;
    proxy_no_cache $cookie_session;
    proxy_buffers 4 32k;
    client_body_buffer_size 128k;

    ## Timeouts
    send_timeout 5m;
    proxy_read_timeout 240;
    proxy_send_timeout 240;
    proxy_connect_timeout 240;
}

The enforcement snippet goes inside each protected location{} block:

nginx-reverse-proxy: templates/snippets/authelia-authrequest.conf.template

# Include inside each location{} you want protected, before proxy_pass.

## Ask Authelia (via the internal location) whether this request is allowed
auth_request /internal/authelia/authz;

## Capture identity headers from Authelia's response
auth_request_set $user   $upstream_http_remote_user;
auth_request_set $groups $upstream_http_remote_groups;
auth_request_set $name   $upstream_http_remote_name;
auth_request_set $email  $upstream_http_remote_email;

## ...and hand them to the backend (header-auth capable apps can trust these,
## e.g. Grafana auth.proxy, as long as the backend is ONLY reachable via nginx)
proxy_set_header Remote-User   $user;
proxy_set_header Remote-Groups $groups;
proxy_set_header Remote-Email  $email;
proxy_set_header Remote-Name   $name;

## On 401, Authelia returns a Location header pointing at the portal with the
## correct ?rd= already filled in — just follow it.
auth_request_set $redirection_url $upstream_http_location;
error_page 401 =302 $redirection_url;

The portal vhost has no auth_request:

nginx-reverse-proxy: templates/auth.conf.template

# Authelia portal vhost: auth.${DOMAIN}
# The portal itself is never behind auth_request.

server {
    listen 80;
    server_name auth.${DOMAIN};
    return 301 https://$host$request_uri;
}

server {
    listen 443 ssl;
    http2 on;
    server_name auth.${DOMAIN};

    # Adjust to wherever your certs live (same as the other vhosts)
    ssl_certificate     ${TLS_CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${TLS_CERT_DIR}/privkey.pem;

    add_header Strict-Transport-Security "max-age=63072000; includeSubDomains" always;
    add_header X-Content-Type-Options    "nosniff" always;
    add_header Referrer-Policy           "strict-origin-when-cross-origin" always;
    # Authelia sets its own CSP/X-Frame-Options; don't override them here.

    location / {
        proxy_pass ${AUTHELIA_UPSTREAM};

        proxy_set_header Host               $host;
        proxy_set_header X-Original-URL     $scheme://$http_host$request_uri;
        proxy_set_header X-Forwarded-Proto  $scheme;
        proxy_set_header X-Forwarded-Host   $http_host;
        proxy_set_header X-Forwarded-URI    $request_uri;
        proxy_set_header X-Forwarded-Ssl    on;
        proxy_set_header X-Forwarded-For    $remote_addr;
        proxy_set_header X-Real-IP          $remote_addr;

        proxy_http_version 1.1;
        proxy_set_header Connection "";
        proxy_buffers 64 256k;
        proxy_read_timeout 360;
        proxy_send_timeout 360;
        proxy_connect_timeout 360;

        proxy_intercept_errors on;
        error_page 502 503 504 = @offline;
    }

    location @offline {
        default_type text/plain;
        return 503 "Authelia is offline\n";
    }
}

A protected app. Two include lines are all it takes:

nginx-reverse-proxy: templates/grafana.conf.template

# Example: a service protected by Authelia (grafana.${DOMAIN}).
# The only Authelia-specific lines are the two includes.

server {
    listen 443 ssl;
    http2 on;
    server_name grafana.${DOMAIN};

    ssl_certificate     ${TLS_CERT_DIR}/fullchain.pem;
    ssl_certificate_key ${TLS_CERT_DIR}/privkey.pem;

    # 1) the internal authz subrequest location
    include /etc/nginx/conf.d/snippets/authelia-location.conf;

    location / {
        # 2) enforce auth for this location
        include /etc/nginx/conf.d/snippets/authelia-authrequest.conf;

        proxy_pass http://app.grafana.svc.cluster.local;   # compose: http://grafana:3000

        proxy_set_header Host              $host;
        proxy_set_header X-Forwarded-Proto $scheme;
        proxy_set_header X-Forwarded-For   $remote_addr;
        proxy_set_header X-Real-IP         $remote_addr;
        proxy_http_version 1.1;
        proxy_set_header Upgrade    $http_upgrade;     # Grafana Live websockets
        proxy_set_header Connection $connection_upgrade;

        proxy_intercept_errors on;
        error_page 502 503 504 = @offline;
    }

    location @offline {
        default_type text/plain;
        return 503 "grafana is offline\n";
    }
}

A few details to get right:

  • Client IP. The snippets pass $remote_addr as X-Forwarded-For. If nginx runs in k3s behind a LoadBalancer or NodePort Service, the default externalTrafficPolicy: Cluster SNATs traffic, so $remote_addr becomes a node IP and every networks: rule sees "internal". Set externalTrafficPolicy: Local on nginx's Service, or configure real_ip_header if there's another proxy in front.
  • $connection_upgrade needs the usual map $http_upgrade $connection_upgrade { default upgrade; '' close; } in the http{} block.
  • Static proxy_pass hosts are resolved at startup. If Authelia's DNS name doesn't exist when nginx starts, nginx won't start. In k3s the Service name exists as soon as the chart is installed, even if the pod is down. In Compose, start Authelia first.
  • Don't cache the subrequest. proxy_cache_bypass and proxy_no_cache on $cookie_session are in the snippet for that reason.

6.2 Traefik (ForwardAuth)

k3s ships Traefik. For any Ingress that goes through Traefik rather than nginx, enable the chart's middleware (traefik.middleware.enabled: true), which renders:

apiVersion: traefik.io/v1alpha1
kind: Middleware
metadata:
  name: forwardauth-authelia
  namespace: authelia
spec:
  forwardAuth:
    address: http://app.authelia.svc.cluster.local:80/api/authz/forward-auth
    trustForwardHeader: true
    authResponseHeaders: [Remote-User, Remote-Groups, Remote-Email, Remote-Name]

Then annotate the app's Ingress:

metadata:
  annotations:
    traefik.ingress.kubernetes.io/router.middlewares: authelia-forwardauth-authelia@kubernetescrd

Referencing a middleware in another namespace needs Traefik's providers.kubernetesCRD.allowCrossNamespace option. If Traefik logs that the middleware doesn't exist, enable that option through a k3s HelmChartConfig for Traefik, or render the middleware into the app's own namespace instead.

6.3 Header-based SSO in backends

Once nginx forwards Remote-User, apps that support proxy authentication can skip their own login. Grafana is the classic example:

[auth.proxy]
enabled = true
header_name = Remote-User
header_property = username
auto_sign_up = true
headers = Email:Remote-Email Name:Remote-Name Groups:Remote-Groups

This is only safe if the backend is unreachable except through nginx. Otherwise anyone can send a Remote-User: admin header. In k3s, that means no NodePort/Ingress on the app itself, and ideally a NetworkPolicy allowing ingress only from the nginx namespace. For anything reachable another way, use OIDC instead (section 8).


7. Deployment B: Kubernetes (Helm)

7.1 Design

The chart follows the same structure as the lab's other charts (job-tracker is the reference), with Authelia-specific additions:

Aspect Choice Why
Service Named app, port 80 → http (9091) Every backend is http://app.<ns>.svc.cluster.local, no port suffix.
Replicas / strategy 1 / Recreate SQLite has one writer. In-memory sessions and regulation are per-pod.
Config Rendered ConfigMap configuration.yml Structured values, with a configOverride escape hatch for anything else.
Generated secrets Secret with lookup reuse + helm.sh/resource-policy: keep Generated once, stable forever, survives helm uninstall.
Users Secret users_database.yml from values, mounted read-only Optional writable: true seeds it onto the PVC with an init container.
Data PVC (keep) at /data db.sqlite3 and notification.txt.
Security UID/GID 10001, drop ALL, no SA token, enableServiceLinks: false The last one stops Service env vars from colliding with Authelia's parser.
Domains Placeholder example.internal in values.yaml Real values only in the gitignored values.local.yaml.

7.2 Chart.yaml

helm/Chart.yaml

apiVersion: v2
name: authelia
description: Authelia SSO / 2FA portal and forward-auth server for the homelab
type: application
version: 0.1.0
appVersion: "4.39.18"
home: https://gitlab.com/andrewmercer-org/authelia
sources:
  - https://github.com/authelia/authelia
keywords:
  - authelia
  - sso
  - 2fa
  - forward-auth
  - oidc
maintainers:
  - name: Andrew Mercer

7.3 values.yaml

helm/values.yaml

# Default values for authelia.
#
# Nothing environment-specific belongs in this file. Put your real domain,
# users and access-control rules in values.local.yaml (gitignored); the
# Makefile layers it on automatically.

# With the defaults (SQLite storage, in-memory sessions) Authelia keeps state
# on a single PVC and in process memory, so this chart runs exactly one pod
# with a Recreate strategy. Scaling out requires BOTH
#   authelia.storage.type: postgres  AND  authelia.session.redis.enabled: true
# and even then regulation/rate-limits stay per-pod. For a homelab, keep 1.
replicaCount: 1

image:
  # Built from ../Containerfile (non-root 10001, pinned).
  # docker.io/authelia/authelia works too; its version tag has no "v".
  repository: registry.gitlab.com/andrewmercer-org/authelia
  pullPolicy: IfNotPresent
  # Overrides the image tag whose default is the chart's appVersion.
  tag: ""

imagePullSecrets: []
nameOverride: ""
fullnameOverride: ""

serviceAccount:
  create: true
  # Authelia never talks to the Kubernetes API.
  automount: false
  annotations: {}
  name: ""

podAnnotations: {}
podLabels: {}

# Matches the image's non-root user (uid/gid 10001), so the mounted volume is
# writable without an init container doing chown.
podSecurityContext:
  fsGroup: 10001
  fsGroupChangePolicy: OnRootMismatch
  seccompProfile:
    type: RuntimeDefault

securityContext:
  runAsNonRoot: true
  runAsUser: 10001
  runAsGroup: 10001
  allowPrivilegeEscalation: false
  # Authelia writes /app/.healthcheck.env at startup; a read-only root
  # filesystem turns that into a logged error on every boot.
  readOnlyRootFilesystem: false
  capabilities:
    drop:
      - ALL

service:
  # ClusterIP by default: the nginx reverse proxy reaches it at
  # http://app.<namespace>.svc.cluster.local (port 80, no suffix).
  #type: NodePort
  type: ClusterIP
  port: 80
  # Pinned so it stays stable across reinstalls if you switch to NodePort.
  nodePort: 30453

# Disabled by default — the nginx reverse proxy fronts auth.<domain>.
# Enable if you want Traefik (k3s built-in) to serve the portal instead.
ingress:
  enabled: false
  className: ""
  annotations: {}
    # cert-manager.io/cluster-issuer: letsencrypt-prod
  hosts:
    - host: auth.example.internal
      paths:
        - path: /
          pathType: Prefix
  tls: []
    # - secretName: authelia-tls
    #   hosts:
    #     - auth.example.internal

# argon2id with the default parameters uses 64 MiB per password verification,
# so leave headroom above the steady-state ~30 MiB.
resources:
  requests:
    cpu: 25m
    memory: 64Mi
  limits:
    cpu: 500m
    memory: 256Mi

env:
  port: 9091
  tz: UTC

persistence:
  enabled: true
  # Leave blank to use the cluster's default StorageClass (local-path on k3s).
  storageClassName: ""
  # SQLite needs real POSIX locking — keep ReadWriteOnce on a local volume.
  accessMode: ReadWriteOnce
  size: 1Gi
  # Bring your own PVC (e.g. restored from backup or migrated from compose).
  existingClaim: ""

strategy:
  type: Recreate

nodeSelector: {}
tolerations: []
affinity: {}

livenessProbe:
  httpGet:
    path: /api/health
    port: http
  initialDelaySeconds: 10
  periodSeconds: 30
  timeoutSeconds: 5

readinessProbe:
  httpGet:
    path: /api/health
    port: http
  initialDelaySeconds: 5
  periodSeconds: 10
  timeoutSeconds: 5

# Used only when authelia.authentication.file.writable is true.
initImage:
  repository: docker.io/library/busybox
  tag: "1.37"
  pullPolicy: IfNotPresent

###############################################################################
# Secrets
###############################################################################
secrets:
  # Name of a pre-existing Secret to use instead of the chart-managed one.
  # Required keys: jwt-secret, session-secret, storage-encryption-key
  # Plus, as enabled: redis-password, postgres-password, smtp-password,
  #                   ldap-password, oidc-hmac-secret, oidc-jwks-key (PEM)
  existingSecret: ""

  # Chart-managed Secret: jwt/session/storage/OIDC secrets are GENERATED on
  # first install and preserved on every upgrade (via lookup), and the Secret
  # carries helm.sh/resource-policy: keep. Run `make secrets-to-pass` once so
  # the storage encryption key exists somewhere other than the cluster.
  #
  # Passwords for external systems can't be generated — supply them here
  # (ideally via values.local.yaml or --set from pass). Once stored they are
  # reused if left blank on later upgrades.
  redisPassword: ""
  postgresPassword: ""
  smtpPassword: ""
  ldapPassword: ""

###############################################################################
# Authelia configuration (rendered into configuration.yml)
###############################################################################
authelia:
  # Root domain the session cookie is scoped to. The portal is served at
  # https://<subdomain>.<domain>. Must not be a public suffix.
  domain: example.internal
  subdomain: auth
  # Where users land after logging in directly at the portal (optional).
  defaultRedirectionURL: ""

  theme: auto            # light | dark | grey | oled | auto

  log:
    level: info          # trace | debug | info | warn | error
    format: text         # json for Loki/Vector

  metrics:
    enabled: false
    port: 9959

  totp:
    issuer: ""           # defaults to authelia.domain

  webauthn:
    displayName: Authelia
    enablePasskeyLogin: true

  session:
    name: authelia_session
    sameSite: lax
    inactivity: 1 hour
    expiration: 12 hours
    rememberMe: 1 month
    redis:
      # Without Redis, sessions live in pod memory: every restart logs
      # everyone out. Fine for a lab; enable to keep sessions across restarts.
      enabled: false
      host: redis.redis.svc.cluster.local
      port: 6379
      databaseIndex: 0

  regulation:
    modes:
      - user             # add "ip" to ban source addresses too (4.39+)
    maxRetries: 3
    findTime: 2 minutes
    banTime: 5 minutes

  passwordPolicy:
    zxcvbn:
      enabled: true
      minScore: 3

  # Named network lists referenced by access-control rules as `networks: <name>`
  networks:
    internal:
      - 10.0.0.0/8
      - 172.16.0.0/12
      - 192.168.0.0/16

  accessControl:
    defaultPolicy: deny
    # Rendered with `tpl`, so {{ .Values.authelia.domain }} works in here.
    # First match wins — specific rules before wildcards, and follow any
    # subject-restricted rule with an explicit deny for the same domain.
    rules:
      - domain: '{{ .Values.authelia.subdomain }}.{{ .Values.authelia.domain }}'
        policy: bypass
      - domain: '*.{{ .Values.authelia.domain }}'
        policy: two_factor

  authentication:
    backend: file        # file | ldap

    file:
      # false: users_database.yml is mounted read-only from a Secret; password
      #        reset/change are disabled; manage users via values + upgrade.
      # true:  the Secret seeds /data/users_database.yml on first start only;
      #        Authelia owns the file after that (self-service reset works),
      #        and later changes to `users` below are NOT applied.
      writable: false
      # Use your own Secret (key: users_database.yml) instead of `users`.
      existingSecret: ""
      # Hash with `make hash-password` or
      #   docker run --rm docker.io/authelia/authelia authelia crypto hash generate argon2
      users: {}
        # andrew:
        #   displayname: Andrew
        #   password: "$argon2id$v=19$m=65536,t=3,p=4$..."
        #   email: [email protected]
        #   groups: [admins, users]
      argon2:
        iterations: 3
        memory: 65536
        parallelism: 4

    ldap:
      implementation: lldap     # custom | activedirectory | freeipa | lldap | glauth | ...
      address: ldap://lldap.lldap.svc.cluster.local:3890
      baseDN: dc=example,dc=internal
      user: uid=authelia,ou=people,dc=example,dc=internal
      # password from secrets.ldapPassword / existingSecret key ldap-password

  storage:
    type: local          # local (SQLite on the PVC) | postgres
    postgres:
      address: tcp://postgres.example.internal:5432
      database: authelia
      schema: public
      username: authelia
      # password from secrets.postgresPassword / existingSecret key postgres-password

  notifier:
    type: filesystem     # filesystem (/data/notification.txt) | smtp
    smtp:
      address: submission://postfix.postfix.svc.cluster.local:587
      username: authelia
      sender: "Authelia <[email protected]>"
      subject: "[Authelia] {title}"
      tlsServerName: ""
      tlsSkipVerify: false
      # password from secrets.smtpPassword / existingSecret key smtp-password

  oidc:
    enabled: false
    # Rendered verbatim (tpl'd) under identity_providers.oidc.clients.
    # client_secret must be a digest:
    #   authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72
    clients: []
      # - client_id: grafana
      #   client_name: Grafana
      #   client_secret: '$pbkdf2-sha512$310000$...'
      #   public: false
      #   authorization_policy: two_factor
      #   require_pkce: true
      #   pkce_challenge_method: S256
      #   redirect_uris:
      #     - 'https://grafana.{{ .Values.authelia.domain }}/login/generic_oauth'
      #   scopes: [openid, profile, groups, email]
      #   response_types: [code]
      #   grant_types: [authorization_code]
      #   userinfo_signed_response_alg: none
      #   token_endpoint_auth_method: client_secret_basic

  # Escape hatch: a complete configuration.yml as a string. When set, it is
  # used verbatim INSTEAD of everything rendered from `authelia:` above
  # (secrets still arrive via the *_FILE env vars).
  configOverride: ""

###############################################################################
# Traefik ForwardAuth middleware (k3s ships Traefik)
###############################################################################
# Only needed if some Ingresses go through Traefik instead of the nginx
# reverse proxy. Reference it on an Ingress with:
#   traefik.ingress.kubernetes.io/router.middlewares: <namespace>-<name>@kubernetescrd
traefik:
  middleware:
    enabled: false
    name: forwardauth-authelia

And the local overlay you copy to values.local.yaml (gitignored):

helm/values.local.yaml.example

# Copy to values.local.yaml (gitignored) and fill in. Layered on by the Makefile.

authelia:
  domain: example.com
  defaultRedirectionURL: https://home.example.com

  authentication:
    file:
      users:
        andrew:
          displayname: Andrew
          password: "$argon2id$v=19$m=65536,t=3,p=4$REPLACE$REPLACE"
          email: [email protected]
          groups: [admins, users]

  accessControl:
    defaultPolicy: deny
    rules:
      - domain: 'auth.{{ .Values.authelia.domain }}'
        policy: bypass
      - domain: 'grafana.{{ .Values.authelia.domain }}'
        resources: ['^/api/health$']
        policy: bypass
      - domain:
          - 'grafana.{{ .Values.authelia.domain }}'
          - 'kibana.{{ .Values.authelia.domain }}'
        subject: 'group:admins'
        policy: two_factor
      - domain:
          - 'grafana.{{ .Values.authelia.domain }}'
          - 'kibana.{{ .Values.authelia.domain }}'
        policy: deny
      - domain: 'jellyfin.{{ .Values.authelia.domain }}'
        networks: [internal]
        policy: one_factor
      - domain: '*.{{ .Values.authelia.domain }}'
        policy: two_factor

  # storage:
  #   type: postgres
  #   postgres:
  #     address: tcp://10.0.0.10:5432
  # notifier:
  #   type: smtp

# secrets:
#   postgresPassword: ""   # better: --set secrets.postgresPassword="$(pass show homelab/authelia/postgres)"

Because accessControl.rules and oidc.clients are passed through tpl, you can write 'grafana.{{ .Values.authelia.domain }}' in values and it resolves at render time. Lists replace rather than merge in Helm, so the local file's rules fully replaces the defaults.

7.4 The configuration template

The heart of the chart. It builds configuration.yml from values and wires only the secret sections that are enabled.

helm/templates/configmap.yaml

{{- $a := .Values.authelia -}}
{{- $fileBackend := eq $a.authentication.backend "file" -}}
{{- /* Read-only users file => self-service password changes are impossible */ -}}
{{- $disableSelfService := false -}}
{{- if and $fileBackend (not $a.authentication.file.writable) }}{{ $disableSelfService = true }}{{ end -}}
apiVersion: v1
kind: ConfigMap
metadata:
  name: {{ include "authelia.fullname" . }}-config
  labels:
    {{- include "authelia.labels" . | nindent 4 }}
data:
  configuration.yml: |
{{- if $a.configOverride }}
{{ $a.configOverride | indent 4 }}
{{- else }}
    ---
    # Rendered by the authelia Helm chart. Secrets arrive via AUTHELIA_*_FILE
    # env vars pointing at /secrets — none are in this file.
    theme: {{ $a.theme | quote }}

    server:
      address: 'tcp://:{{ .Values.env.port }}/'

    log:
      level: {{ $a.log.level | quote }}
      format: {{ $a.log.format | quote }}

    telemetry:
      metrics:
        enabled: {{ $a.metrics.enabled }}
        address: 'tcp://:{{ $a.metrics.port }}/metrics'

    totp:
      issuer: {{ $a.totp.issuer | default $a.domain | quote }}
      algorithm: 'SHA1'
      digits: 6
      period: 30
      skew: 1

    webauthn:
      display_name: {{ $a.webauthn.displayName | quote }}
      attestation_conveyance_preference: 'indirect'
      timeout: '60 seconds'
      enable_passkey_login: {{ $a.webauthn.enablePasskeyLogin }}

    identity_validation:
      reset_password:
        jwt_lifespan: '5 minutes'

    authentication_backend:
      password_reset:
        disable: {{ $disableSelfService }}
      password_change:
        disable: {{ $disableSelfService }}
      {{- if $fileBackend }}
      file:
        path: {{ include "authelia.usersPath" . | quote }}
        watch: true
        search:
          email: true
          case_insensitive: true
        password:
          algorithm: 'argon2'
          argon2:
            variant: 'argon2id'
            iterations: {{ $a.authentication.file.argon2.iterations }}
            memory: {{ $a.authentication.file.argon2.memory }}
            parallelism: {{ $a.authentication.file.argon2.parallelism }}
            key_length: 32
            salt_length: 16
      {{- else }}
      ldap:
        implementation: {{ $a.authentication.ldap.implementation | quote }}
        address: {{ $a.authentication.ldap.address | quote }}
        base_dn: {{ $a.authentication.ldap.baseDN | quote }}
        user: {{ $a.authentication.ldap.user | quote }}
      {{- end }}

    password_policy:
      zxcvbn:
        enabled: {{ $a.passwordPolicy.zxcvbn.enabled }}
        min_score: {{ $a.passwordPolicy.zxcvbn.minScore }}

    {{- with $a.networks }}
    definitions:
      network:
        {{- toYaml . | nindent 8 }}
    {{- end }}

    access_control:
      default_policy: {{ $a.accessControl.defaultPolicy | quote }}
      rules:
        {{- tpl (toYaml $a.accessControl.rules) . | nindent 8 }}

    session:
      name: {{ $a.session.name | quote }}
      same_site: {{ $a.session.sameSite | quote }}
      inactivity: {{ $a.session.inactivity | quote }}
      expiration: {{ $a.session.expiration | quote }}
      remember_me: {{ $a.session.rememberMe | quote }}
      cookies:
        - domain: {{ $a.domain | quote }}
          authelia_url: 'https://{{ $a.subdomain }}.{{ $a.domain }}'
          {{- with $a.defaultRedirectionURL }}
          default_redirection_url: {{ . | quote }}
          {{- end }}
      {{- if $a.session.redis.enabled }}
      redis:
        host: {{ $a.session.redis.host | quote }}
        port: {{ $a.session.redis.port }}
        database_index: {{ $a.session.redis.databaseIndex }}
      {{- end }}

    regulation:
      modes:
        {{- toYaml $a.regulation.modes | nindent 8 }}
      max_retries: {{ $a.regulation.maxRetries }}
      find_time: {{ $a.regulation.findTime | quote }}
      ban_time: {{ $a.regulation.banTime | quote }}

    storage:
      {{- if eq $a.storage.type "postgres" }}
      postgres:
        address: {{ $a.storage.postgres.address | quote }}
        database: {{ $a.storage.postgres.database | quote }}
        schema: {{ $a.storage.postgres.schema | quote }}
        username: {{ $a.storage.postgres.username | quote }}
        timeout: '5 seconds'
      {{- else }}
      local:
        path: '/data/db.sqlite3'
      {{- end }}

    notifier:
      disable_startup_check: false
      {{- if eq $a.notifier.type "smtp" }}
      smtp:
        address: {{ $a.notifier.smtp.address | quote }}
        username: {{ $a.notifier.smtp.username | quote }}
        sender: {{ $a.notifier.smtp.sender | quote }}
        subject: {{ $a.notifier.smtp.subject | quote }}
        tls:
          {{- with $a.notifier.smtp.tlsServerName }}
          server_name: {{ . | quote }}
          {{- end }}
          skip_verify: {{ $a.notifier.smtp.tlsSkipVerify }}
      {{- else }}
      filesystem:
        filename: '/data/notification.txt'
      {{- end }}

    {{- if $a.oidc.enabled }}

    identity_providers:
      oidc:
        jwks:
          - key_id: 'main'
            algorithm: 'RS256'
            use: 'sig'
            # Evaluated by Authelia's own template filter at startup, not by Helm.
            key: {{`{{ secret "/secrets/oidc-jwks-key" | mindent 10 "|" | msquote }}`}}
        cors:
          endpoints: ['authorization', 'token', 'revocation', 'introspection', 'userinfo']
          allowed_origins_from_client_redirect_uris: true
        clients:
          {{- tpl (toYaml $a.oidc.clients) . | nindent 10 }}
    {{- end }}
    ...
{{- end }}

Note the OIDC key: line. It is a Go raw string in backticks, so Helm emits {{ secret "/secrets/oidc-jwks-key" | mindent 10 "|" | msquote }} literally, and Authelia's template filter then inlines the PEM at startup. That's two template engines in sequence, which is why the escaping matters.

7.5 Secrets

helm/templates/secret.yaml

{{- if not .Values.secrets.existingSecret }}
{{- $a := .Values.authelia -}}
{{- $name := include "authelia.secretName" . -}}
{{- /*
  Reuse whatever is already in the cluster so generated secrets are stable
  across upgrades. `lookup` returns nothing under `helm template` / --dry-run,
  which is why rendered output shows fresh random values there — the real
  install/upgrade path always sees the live Secret.
*/ -}}
{{- $existing := dict -}}
{{- with (lookup "v1" "Secret" .Release.Namespace $name) }}
{{- $existing = .data | default dict -}}
{{- end }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ $name }}
  labels:
    {{- include "authelia.labels" . | nindent 4 }}
  annotations:
    # Losing storage-encryption-key means every user re-enrolls 2FA.
    # Never let helm delete this Secret.
    helm.sh/resource-policy: keep
type: Opaque
data:
  jwt-secret: {{ index $existing "jwt-secret" | default (randAlphaNum 64 | b64enc) | quote }}
  session-secret: {{ index $existing "session-secret" | default (randAlphaNum 64 | b64enc) | quote }}
  storage-encryption-key: {{ index $existing "storage-encryption-key" | default (randAlphaNum 64 | b64enc) | quote }}
  {{- if $a.session.redis.enabled }}
  redis-password: {{ .Values.secrets.redisPassword | b64enc | default (index $existing "redis-password") | required "secrets.redisPassword is required when authelia.session.redis.enabled" | quote }}
  {{- end }}
  {{- if eq $a.storage.type "postgres" }}
  postgres-password: {{ .Values.secrets.postgresPassword | b64enc | default (index $existing "postgres-password") | required "secrets.postgresPassword is required when authelia.storage.type=postgres" | quote }}
  {{- end }}
  {{- if eq $a.notifier.type "smtp" }}
  smtp-password: {{ .Values.secrets.smtpPassword | b64enc | default (index $existing "smtp-password") | required "secrets.smtpPassword is required when authelia.notifier.type=smtp" | quote }}
  {{- end }}
  {{- if eq $a.authentication.backend "ldap" }}
  ldap-password: {{ .Values.secrets.ldapPassword | b64enc | default (index $existing "ldap-password") | required "secrets.ldapPassword is required when authelia.authentication.backend=ldap" | quote }}
  {{- end }}
  {{- if $a.oidc.enabled }}
  oidc-hmac-secret: {{ index $existing "oidc-hmac-secret" | default (randAlphaNum 64 | b64enc) | quote }}
  oidc-jwks-key: {{ index $existing "oidc-jwks-key" | default (genPrivateKey "rsa" | b64enc) | quote }}
  {{- end }}
{{- end }}

lookup makes the generated values sticky. On first install nothing exists, so random values (and an RSA-4096 key via genPrivateKey when OIDC is enabled) are created. On every later upgrade the live values are read back and re-emitted unchanged. Two consequences follow:

  • helm template and --dry-run show fresh random values, because lookup returns nothing without a live cluster. That is expected, and the real upgrade path is unaffected. It also makes helm diff show the Secret as changing on every run; ignore that line.
  • Back up once, right after the first install: make secrets-to-pass copies the generated keys into pass under authelia/k3s/. If you'd rather make pass the source of truth from the start, create the Secret yourself and set secrets.existingSecret:
kubectl create namespace authelia
kubectl -n authelia create secret generic authelia-secrets \
  --from-literal=jwt-secret="$(pass show authelia/jwt_secret)" \
  --from-literal=session-secret="$(pass show authelia/session_secret)" \
  --from-literal=storage-encryption-key="$(pass show authelia/storage_encryption_key)"

Reusing the Compose stack's storage_encryption_key here is exactly what makes the migration in 7.9 work.

7.6 Users

helm/templates/users-secret.yaml

{{- if and (eq .Values.authelia.authentication.backend "file") (not .Values.authelia.authentication.file.existingSecret) }}
apiVersion: v1
kind: Secret
metadata:
  name: {{ include "authelia.usersSecretName" . }}
  labels:
    {{- include "authelia.labels" . | nindent 4 }}
type: Opaque
stringData:
  users_database.yml: |
    ---
    # Rendered by the authelia Helm chart from .Values.authelia.authentication.file.users
    {{- if .Values.authelia.authentication.file.users }}
    users:
      {{- toYaml .Values.authelia.authentication.file.users | nindent 6 }}
    {{- else }}
    # No users defined — set authelia.authentication.file.users in values.local.yaml.
    users: {}
    {{- end }}
{{- end }}

There are two modes:

  • writable: false (default) — the Secret is mounted read-only at /users, password reset/change are disabled automatically, and user changes are made in values.local.yaml followed by make install. The checksum/users annotation rolls the pod on change. watch: true would pick it up anyway once kubelet syncs the Secret, but the roll is immediate.
  • writable: true — an init container copies the Secret to /data/users_database.yml only if it doesn't already exist, and Authelia owns the file from then on. Self-service password reset works (configure SMTP), but later edits to users in values are ignored. Change users on the PVC, or delete the file to re-seed.

7.7 Deployment

helm/templates/deployment.yaml

{{- $a := .Values.authelia -}}
{{- $fileBackend := eq $a.authentication.backend "file" -}}
{{- $writableUsers := and $fileBackend $a.authentication.file.writable -}}
apiVersion: apps/v1
kind: Deployment
metadata:
  name: {{ include "authelia.fullname" . }}
  labels:
    {{- include "authelia.labels" . | nindent 4 }}
spec:
  replicas: {{ .Values.replicaCount }}
  strategy:
    # SQLite has exactly one writer and sessions are in memory by default —
    # never let a rolling update run two pods at once.
    {{- toYaml .Values.strategy | nindent 4 }}
  selector:
    matchLabels:
      {{- include "authelia.selectorLabels" . | nindent 6 }}
  template:
    metadata:
      annotations:
        # Roll the pod when configuration or users change. (Authelia also
        # hot-reloads users_database.yml via `watch: true`, but a Secret
        # update can take a minute to propagate to the mount.)
        checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
        {{- if and $fileBackend (not $a.authentication.file.existingSecret) }}
        checksum/users: {{ include (print $.Template.BasePath "/users-secret.yaml") . | sha256sum }}
        {{- end }}
        {{- if $a.metrics.enabled }}
        prometheus.io/scrape: "true"
        prometheus.io/port: {{ $a.metrics.port | quote }}
        prometheus.io/path: /metrics
        {{- end }}
        {{- with .Values.podAnnotations }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
      labels:
        {{- include "authelia.labels" . | nindent 8 }}
        {{- with .Values.podLabels }}
        {{- toYaml . | nindent 8 }}
        {{- end }}
    spec:
      {{- with .Values.imagePullSecrets }}
      imagePullSecrets:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      serviceAccountName: {{ include "authelia.serviceAccountName" . }}
      automountServiceAccountToken: {{ .Values.serviceAccount.automount }}
      # Authelia's env var parser rejects unknown AUTHELIA_* variables, and
      # Kubernetes service links inject <SVC>_PORT style vars for every
      # Service in the namespace. Turn them off so nothing collides.
      enableServiceLinks: false
      securityContext:
        {{- toYaml .Values.podSecurityContext | nindent 8 }}
      {{- if $writableUsers }}
      initContainers:
        - name: seed-users
          image: "{{ .Values.initImage.repository }}:{{ .Values.initImage.tag }}"
          imagePullPolicy: {{ .Values.initImage.pullPolicy }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          command:
            - sh
            - -c
            - |
              if [ -s /data/users_database.yml ]; then
                echo "users_database.yml already on the PVC — leaving it alone"
              else
                cp /users/users_database.yml /data/users_database.yml
                chmod 0600 /data/users_database.yml
                echo "seeded /data/users_database.yml from Secret"
              fi
          volumeMounts:
            - name: data
              mountPath: /data
            - name: users
              mountPath: /users
              readOnly: true
      {{- end }}
      containers:
        - name: {{ .Chart.Name }}
          securityContext:
            {{- toYaml .Values.securityContext | nindent 12 }}
          image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
          imagePullPolicy: {{ .Values.image.pullPolicy }}
          args:
            - --config
            - /config/configuration.yml
          ports:
            - name: http
              containerPort: {{ .Values.env.port }}
              protocol: TCP
            {{- if $a.metrics.enabled }}
            - name: metrics
              containerPort: {{ $a.metrics.port }}
              protocol: TCP
            {{- end }}
          env:
            - name: TZ
              value: {{ .Values.env.tz | quote }}
            - name: X_AUTHELIA_CONFIG_FILTERS
              value: template
            - name: AUTHELIA_IDENTITY_VALIDATION_RESET_PASSWORD_JWT_SECRET_FILE
              value: /secrets/jwt-secret
            - name: AUTHELIA_SESSION_SECRET_FILE
              value: /secrets/session-secret
            - name: AUTHELIA_STORAGE_ENCRYPTION_KEY_FILE
              value: /secrets/storage-encryption-key
            {{- if $a.session.redis.enabled }}
            - name: AUTHELIA_SESSION_REDIS_PASSWORD_FILE
              value: /secrets/redis-password
            {{- end }}
            {{- if eq $a.storage.type "postgres" }}
            - name: AUTHELIA_STORAGE_POSTGRES_PASSWORD_FILE
              value: /secrets/postgres-password
            {{- end }}
            {{- if eq $a.notifier.type "smtp" }}
            - name: AUTHELIA_NOTIFIER_SMTP_PASSWORD_FILE
              value: /secrets/smtp-password
            {{- end }}
            {{- if eq $a.authentication.backend "ldap" }}
            - name: AUTHELIA_AUTHENTICATION_BACKEND_LDAP_PASSWORD_FILE
              value: /secrets/ldap-password
            {{- end }}
            {{- if $a.oidc.enabled }}
            - name: AUTHELIA_IDENTITY_PROVIDERS_OIDC_HMAC_SECRET_FILE
              value: /secrets/oidc-hmac-secret
            {{- end }}
          livenessProbe:
            {{- toYaml .Values.livenessProbe | nindent 12 }}
          readinessProbe:
            {{- toYaml .Values.readinessProbe | nindent 12 }}
          resources:
            {{- toYaml .Values.resources | nindent 12 }}
          volumeMounts:
            - name: config
              mountPath: /config
              readOnly: true
            - name: secrets
              mountPath: /secrets
              readOnly: true
            - name: data
              mountPath: /data
            {{- if and $fileBackend (not $writableUsers) }}
            - name: users
              mountPath: /users
              readOnly: true
            {{- end }}
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: config
          configMap:
            name: {{ include "authelia.fullname" . }}-config
        - name: secrets
          secret:
            secretName: {{ include "authelia.secretName" . }}
            # group-readable: files are root-owned, fsGroup 10001 grants access
            defaultMode: 0440
        {{- if $fileBackend }}
        - name: users
          secret:
            secretName: {{ include "authelia.usersSecretName" . }}
            defaultMode: 0440
        {{- end }}
        - name: data
          {{- if .Values.persistence.enabled }}
          persistentVolumeClaim:
            claimName: {{ .Values.persistence.existingClaim | default (include "authelia.fullname" .) }}
          {{- else }}
          emptyDir: {}
          {{- end }}
        - name: tmp
          emptyDir:
            sizeLimit: 64Mi
      {{- with .Values.nodeSelector }}
      nodeSelector:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.affinity }}
      affinity:
        {{- toYaml . | nindent 8 }}
      {{- end }}
      {{- with .Values.tolerations }}
      tolerations:
        {{- toYaml . | nindent 8 }}
      {{- end }}

The remaining templates (service.yaml, serviceaccount.yaml, pvc.yaml, ingress.yaml, traefik-middleware.yaml, _helpers.tpl, NOTES.txt) are standard and identical in shape to the other lab charts. The Service is literally named app, and the PVC carries helm.sh/resource-policy: keep.

7.8 Makefile and install

helm/Makefile

# authelia - Makefile
# Wraps `helm upgrade --install` and the rest of the day-to-day lifecycle
# into simple commands. Same conventions as job-tracker's chart Makefile,
# plus a gitignored values.local.yaml (real domain, users, rules) that is
# layered on automatically when present.

RELEASE   := authelia
CHART_DIR := .

# Override on the command line if needed: make install NAMESPACE=other-ns
NAMESPACE ?= authelia

# Real domain/users/rules live here and never get committed.
VALUES_LOCAL ?= values.local.yaml
VALUES_ARGS  := $(if $(wildcard $(VALUES_LOCAL)),-f $(VALUES_LOCAL),)

# pass(1) prefix used by secrets-to-pass
PASS_PREFIX ?= authelia/k3s

.PHONY: help namespace install upgrade template lint diff status uninstall \
        logs validate hash-password restart secrets-to-pass

.DEFAULT_GOAL := help

COLOR_RESET   = \033[0m
COLOR_INFO    = \033[36m
COLOR_SUCCESS = \033[32m
COLOR_WARNING = \033[33m
COLOR_ERROR   = \033[31m

help: ## Display this help message
    @echo "$(COLOR_INFO)authelia (Helm) - Available Commands$(COLOR_RESET)"
    @awk 'BEGIN {FS = ":.*##"; printf "\nUsage:\n  make \033[36m<target>\033[0m\n"} \
          /^[a-zA-Z_-]+:.*?##/ { printf "  \033[36m%-20s\033[0m %s\n", $$1, $$2 } \
          /^##@/ { printf "\n\033[1m%s\033[0m\n", substr($$0, 5) }' $(MAKEFILE_LIST)
    @echo ""
    @echo "  values.local.yaml: $(if $(VALUES_ARGS),$(COLOR_SUCCESS)found$(COLOR_RESET),$(COLOR_WARNING)missing (cp values.local.yaml.example values.local.yaml)$(COLOR_RESET))"

##@ Deploy

namespace: ## Create the target namespace if it doesn't already exist
    @kubectl get namespace $(NAMESPACE) >/dev/null 2>&1 || kubectl create namespace $(NAMESPACE)

install: namespace ## helm upgrade --install (idempotent -- installs if missing, upgrades if present)
    @echo "$(COLOR_INFO)helm upgrade --install $(RELEASE) in $(NAMESPACE)...$(COLOR_RESET)"
    @helm upgrade --install $(RELEASE) $(CHART_DIR) -n $(NAMESPACE) --create-namespace $(VALUES_ARGS)
    @echo "$(COLOR_SUCCESS)✓ Deploy complete$(COLOR_RESET)"

upgrade: install ## Alias for install

template: ## Render manifests locally without applying (review before deploy)
    @helm template $(RELEASE) $(CHART_DIR) -n $(NAMESPACE) $(VALUES_ARGS)

lint: ## helm lint the chart
    @helm lint $(CHART_DIR) $(VALUES_ARGS)

diff: ## Show what an upgrade would change (requires the helm-diff plugin)
    @helm diff upgrade $(RELEASE) $(CHART_DIR) -n $(NAMESPACE) $(VALUES_ARGS)

status: ## Show release + pod status
    @helm status $(RELEASE) -n $(NAMESPACE)
    @kubectl get pods -l app.kubernetes.io/name=authelia -n $(NAMESPACE)

uninstall: ## helm uninstall (the secrets Secret and PVC are kept by resource-policy)
    @helm uninstall $(RELEASE) -n $(NAMESPACE)

##@ Operate

logs: ## Follow Authelia logs
    @kubectl logs -f deploy/$(RELEASE) -n $(NAMESPACE)

restart: ## Rolling restart (Recreate strategy, so expect a few seconds of downtime)
    @kubectl rollout restart deploy/$(RELEASE) -n $(NAMESPACE)

validate: ## Run `authelia validate-config` inside the running pod
    @kubectl exec deploy/$(RELEASE) -n $(NAMESPACE) -- authelia validate-config --config /config/configuration.yml

hash-password: ## Interactively hash a password (argon2id) with the in-cluster binary
    @kubectl exec -it deploy/$(RELEASE) -n $(NAMESPACE) -- authelia crypto hash generate argon2

secrets-to-pass: ## Copy the chart-generated secrets into pass (do this once after first install!)
    @for k in jwt-secret session-secret storage-encryption-key oidc-hmac-secret oidc-jwks-key; do \
      v=$$(kubectl get secret $(RELEASE)-secrets -n $(NAMESPACE) -o jsonpath="{.data.$$k}" 2>/dev/null | base64 -d); \
      if [ -n "$$v" ]; then printf '%s\n' "$$v" | pass insert -m -f "$(PASS_PREFIX)/$$k" >/dev/null && echo "  stored $(PASS_PREFIX)/$$k"; fi; \
    done
    @echo "$(COLOR_SUCCESS)✓ Secrets backed up to pass$(COLOR_RESET)"
cd helm
cp values.local.yaml.example values.local.yaml && $EDITOR values.local.yaml
make lint && make template | less       # review
make install
make secrets-to-pass                    # back up generated secrets — do not skip
make status && make logs

Then point nginx at it by setting AUTHELIA_UPSTREAM=http://app.authelia.svc.cluster.local in the reverse proxy's environment, adding auth.conf.template, and adding the two include lines to each protected vhost.

7.9 Migrating from Compose to k3s

Since k3s and Docker share the host, this is a direct file copy, the same pattern used for the other apps. The one hard requirement is the same storage encryption key. Otherwise the copied database is unreadable and Authelia refuses to start with an encryption key mismatch.

# 1. Create the Secret from the SAME pass entries the compose stack used
kubectl create namespace authelia
kubectl -n authelia create secret generic authelia-secrets \
  --from-literal=jwt-secret="$(pass show authelia/jwt_secret)" \
  --from-literal=session-secret="$(pass show authelia/session_secret)" \
  --from-literal=storage-encryption-key="$(pass show authelia/storage_encryption_key)"
# values.local.yaml:  secrets: { existingSecret: authelia-secrets }

# 2. Install, then scale to zero so nothing writes to the PVC
make install
kubectl -n authelia scale deploy/authelia --replicas=0

# 3. Find both sides on the host
SRC=$(docker volume inspect authelia_authelia_data -f '{{ .Mountpoint }}')
PV=$(kubectl -n authelia get pvc authelia -o jsonpath='{.spec.volumeName}')
DST=$(kubectl get pv "$PV" -o jsonpath='{.spec.local.path}{.spec.hostPath.path}')

# 4. Stop compose, copy, fix ownership
( cd /opt/containers/authelia && docker compose stop authelia )
sudo cp -a "$SRC"/db.sqlite3 "$DST"/
sudo chown -R 10001:10001 "$DST"

# 5. Start it
kubectl -n authelia scale deploy/authelia --replicas=1
make logs

Users keep their TOTP and WebAuthn registrations. Sessions don't carry over (they lived in the Compose Valkey), so everyone logs in once.


8. Authelia as an OpenID Connect provider

Forward-auth protects apps that have no login. OIDC is better for apps that do support SSO. The app gets a real identity with groups, there's no reliance on trusted headers, and it works for API/mobile clients that can't follow a browser redirect through a proxy.

8.1 Enable the provider

Compose: run make secrets-oidc, uncomment the oidc_* secrets in compose.yaml and the identity_providers block in configuration.yml, then wrap the key: expression in double braces as the comment describes.

Helm: set authelia.oidc.enabled: true. The chart generates the HMAC secret and RSA-4096 signing key on the next upgrade.

Discovery is then at https://auth.<domain>/.well-known/openid-configuration.

8.2 Register a client (Grafana)

Generate a client secret. Keep the plaintext for the app and put the digest in Authelia:

docker run --rm docker.io/authelia/authelia:4.39.18 \
  authelia crypto hash generate pbkdf2 --variant sha512 --random --random.length 72 --random.charset rfc3986
# Random Password: <plaintext → Grafana>
# Digest: $pbkdf2-sha512$310000$…  (→ Authelia)

In Authelia (Helm values shown):

authelia:
  oidc:
    enabled: true
    clients:
      - client_id: grafana
        client_name: Grafana
        client_secret: '$pbkdf2-sha512$310000$...'
        public: false
        authorization_policy: two_factor
        require_pkce: true
        pkce_challenge_method: S256
        redirect_uris:
          - 'https://grafana.{{ .Values.authelia.domain }}/login/generic_oauth'
        scopes: [openid, profile, groups, email]
        response_types: [code]
        grant_types: [authorization_code]
        userinfo_signed_response_alg: none
        token_endpoint_auth_method: client_secret_basic

In Grafana:

[auth.generic_oauth]
enabled = true
name = Authelia
icon = signin
client_id = grafana
client_secret = <plaintext>
scopes = openid profile email groups
empty_scopes = false
auth_url = https://auth.example.com/api/oidc/authorization
token_url = https://auth.example.com/api/oidc/token
api_url = https://auth.example.com/api/oidc/userinfo
login_attribute_path = preferred_username
groups_attribute_path = groups
name_attribute_path = name
use_pkce = true
role_attribute_path = contains(groups, 'admins') && 'Admin' || 'Viewer'

Remove Grafana from the nginx auth_request includes once OIDC works. Protecting it both ways causes a double login.

8.3 The 4.39 ID token change

Since 4.39, ID tokens contain only the standard claims unless the client asks for more with the claims parameter. Clients that expected email, groups or preferred_username in the ID token (rather than from the userinfo endpoint) may break. The fix is a claims policy:

identity_providers:
  oidc:
    claims_policies:
      legacy_id_token:
        id_token: [email, email_verified, preferred_username, name, groups]
    clients:
      - client_id: some-older-app
        claims_policy: legacy_id_token
        # ...

9. Operations

9.1 Useful CLI commands

All of these run inside the container: docker compose exec authelia authelia … or kubectl -n authelia exec deploy/authelia -- authelia ….

Task Command
Validate config authelia validate-config --config /config/configuration.yml
Test a rule authelia access-control check-policy --config … --url https://x.example.com --username bob --groups users
Hash a password authelia crypto hash generate argon2
Random secret authelia crypto rand --length 64 --charset alphanumeric
Remove a user's TOTP authelia storage user totp delete bob --config …
List WebAuthn keys authelia storage user webauthn list bob --config …
List / remove bans (4.39) authelia storage bans user list --config … / … user revoke bob
Rotate storage key authelia storage encryption change-key --new-encryption-key "$(pass show …)" --config …
Schema migration status authelia storage migrate list-up --config …

The storage subcommands need the storage encryption key in the environment, which the running container already has.

9.2 Logs and metrics

log.format: json is easiest to ship through the Vector → Loki pipeline. Useful fields are level, msg, method, path and remote_ip. Failed logins log at error with the username and IP, which makes a good Grafana alert query:

{app="authelia"} | json | msg =~ "Unsuccessful .* authentication attempt.*"

With metrics.enabled: true, Prometheus metrics appear on :9959/metrics. The chart adds the port to the Service and prometheus.io/* pod annotations. Interesting series include authelia_authn (first factor attempts by success), authelia_authn_second_factor and authelia_authz (authorization decisions by status).

9.3 Backups

What Where How
Storage key and secrets pass Already there if you followed either path.
db.sqlite3 /data volume / PVC Compose: make backup (stops Authelia briefly, then tars the volume). k3s: scale to 0, copy from the PV path, scale to 1 — or move storage to PostgreSQL and use its backups.
users_database.yml Git (hashes only) or PVC if writable Hashes in a private repo are acceptable. Plaintext never.
Configuration Git No secrets in it by design.

9.4 Upgrades

Authelia uses semantic versioning within 4.x, but minor versions (4.38 → 4.39) routinely deprecate keys and occasionally change behavior. For example, 4.39 changed OIDC ID token claims and reworked the container. The procedure:

  1. Read the release notes on the Authelia blog for the minor version.
  2. Bump AUTHELIA_VERSION (Containerfile CI) or appVersion (chart) and build.
  3. Back up db.sqlite3. Schema migrations run automatically on start and are not reversible by downgrade.
  4. Run validate-config with the new image against the current config. Deprecation warnings are fine, errors are not.
  5. Deploy, watch the logs for errors, and confirm /api/health returns OK before relying on it.

9.5 Troubleshooting

Symptom Likely cause Fix
Redirect loop between app and portal Cookie domain doesn't cover the app's hostname, or authelia_url is not under that domain App and portal must share the session.cookies[].domain parent.
Portal says "There was an issue retrieving the current user state" Proxy isn't forwarding X-Forwarded-Proto/Host, or the portal is served over plain HTTP Use the auth.conf.template headers. Authelia requires HTTPS for authelia_url.
nginx returns 500 on protected hosts auth_request subrequest can't reach Authelia Check AUTHELIA_UPSTREAM. kubectl exec into the nginx pod and wget -qO- $AUTHELIA_UPSTREAM/api/health.
Everyone matches networks: internal Client IP is SNAT'd externalTrafficPolicy: Local on nginx's Service, or fix real_ip_header.
Startup error "configuration environment variable not expected" Stray AUTHELIA_* env var Don't env_file your .env. In k8s, check for a Service named authelia and keep enableServiceLinks: false.
Startup error about encryption key / "the configured encryption key does not appear to be valid" Database was created with a different storage.encryption_key Restore the original key from pass.
Startup error about NTP / time sync Clock drift check on start Fix NTP on the host. As a last resort, set ntp.disable_startup_check: true.
TOTP codes always rejected Host clock drift, or non-default TOTP algorithm/digits Sync the clock. Keep SHA1/6/30.
Users can't reset passwords Read-only users file (by design here) or filesystem notifier Use writable: true plus SMTP, or edit the users file.
OIDC app suddenly missing email/groups after 4.39 ID token claim change Add a claims_policy (section 8.3).

10. Hardening checklist

  • access_control.default_policy: deny, with explicit deny rules following every subject-restricted rule.
  • two_factor on everything reachable from outside the LAN. Reserve one_factor for networks: internal rules.
  • regulation.modes: [user, ip] if the portal is internet-facing.
  • The storage encryption key is backed up in pass, and never exists only in the cluster.
  • The users file holds argon2id hashes only, and the repo holding it is private.
  • Backends that trust Remote-* headers are unreachable except through the proxy (no NodePort, and a NetworkPolicy).
  • Switch to the SMTP notifier before enrolling real users. The filesystem notifier is for bootstrapping.
  • HSTS on the portal vhost, TLS 1.2+ only, and the portal itself never behind auth_request.
  • Container runs as a non-root user with all capabilities dropped and no-new-privileges. In Kubernetes, no service-account token.
  • Upgrades are pinned and deliberate. Read the minor-version release notes before bumping.
  • Alert on bursts of failed authentication from the logs.

11. References