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
Containerfileplus a Compose stack with Valkey for sessions and file-based secrets sourced frompass. - Kubernetes — a Helm chart that follows the same conventions as the rest of the lab's charts (Service named
appon port 80,helm.sh/resource-policy: keepon 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:
- The YAML file(s) given with
--config(several files, or a directory, are merged). - Environment variables. Any key can be set as
AUTHELIA_<PATH>, with.becoming_. For examplesession.redis.passwordbecomesAUTHELIA_SESSION_REDIS_PASSWORD. - The
_FILEvariant 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 ofdocker 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.envcontaining something likeAUTHELIA_VERSION=into the container withenv_file. The Compose stack below interpolates.envbut passes only an explicitenvironment:block. In Kubernetes, a Service namedautheliainjectsAUTHELIA_PORT=tcp://…into every pod in the namespace through service links. The chart names its Serviceappand setsenableServiceLinks: falseanyway. - Template filter. With
X_AUTHELIA_CONFIG_FILTERS=template, the YAML is first run through a Go template engine that providesenv,secret,mindent,msquoteand 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_VERSIONand CI rebuilds. - UID 10001 baked in, matching every other image in the lab, so volumes and PVCs have predictable ownership.
- Pre-created directories.
/config,/dataand/secretsalready exist with the right owner. Because the base image has no shell tooling forchown, ownership comes fromCOPY --chownout of a BusyBox helper stage. - A healthcheck fix for non-root. Authelia writes
/app/.healthcheck.envat startup, and upstream's healthcheck script reads it./appis root-owned, so a non-root process can't create that file. Pre-creating it owned by 10001 keeps the inheritedHEALTHCHECKworking.
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_netis external, so the nginx reverse proxy reaches Authelia athttp://authelia:9091by container name, and no host port is published.- Secrets are Compose file secrets, mounted at
/run/secrets/<name>and referenced through*_FILEvariables. Nothing sensitive appears indocker 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: ALLandno-new-privileges.read_only: trueis not set, because Authelia writes the healthcheck env file into/appat 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_addrasX-Forwarded-For. If nginx runs in k3s behind aLoadBalancerorNodePortService, the defaultexternalTrafficPolicy: ClusterSNATs traffic, so$remote_addrbecomes a node IP and everynetworks:rule sees "internal". SetexternalTrafficPolicy: Localon nginx's Service, or configurereal_ip_headerif there's another proxy in front. $connection_upgradeneeds the usualmap $http_upgrade $connection_upgrade { default upgrade; '' close; }in thehttp{}block.- Static
proxy_passhosts 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_bypassandproxy_no_cacheon$cookie_sessionare 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 templateand--dry-runshow fresh random values, becauselookupreturns nothing without a live cluster. That is expected, and the real upgrade path is unaffected. It also makeshelm diffshow the Secret as changing on every run; ignore that line.- Back up once, right after the first install:
make secrets-to-passcopies the generated keys intopassunderauthelia/k3s/. If you'd rather makepassthe source of truth from the start, create the Secret yourself and setsecrets.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 invalues.local.yamlfollowed bymake install. Thechecksum/usersannotation rolls the pod on change.watch: truewould 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.ymlonly if it doesn't already exist, and Authelia owns the file from then on. Self-service password reset works (configure SMTP), but later edits tousersin 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:
- Read the release notes on the Authelia blog for the minor version.
- Bump
AUTHELIA_VERSION(Containerfile CI) orappVersion(chart) and build. - Back up
db.sqlite3. Schema migrations run automatically on start and are not reversible by downgrade. - Run
validate-configwith the new image against the current config. Deprecation warnings are fine, errors are not. - Deploy, watch the logs for errors, and confirm
/api/healthreturns 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 explicitdenyrules following every subject-restricted rule.two_factoron everything reachable from outside the LAN. Reserveone_factorfornetworks: internalrules.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¶
- Authelia documentation: https://www.authelia.com/configuration/prologue/introduction/
- Proxy integration (nginx): https://www.authelia.com/integration/proxies/nginx/
- Access control: https://www.authelia.com/configuration/security/access-control/
- OpenID Connect provider: https://www.authelia.com/configuration/identity-providers/openid-connect/provider/
- OIDC client integrations: https://www.authelia.com/integration/openid-connect/introduction/
- 4.39 release notes: https://www.authelia.com/blog/4.39-release-notes/
- Releases: https://github.com/authelia/authelia/releases