Preamble#
In my Kubernetes cluster I host many services, some of which require authentication. I wanted to centralise the management of that authentication for all of my cluster’s users.
There are many popular choices to accomplish this, some proprietary enterprise level like Okta and Cognito. Also some really good open source ones like Authentik, Kanidm and Keycloak.
Today I’ll be showing Authelia. I chose it primarily because it allows user management via a YAML file to keep a database of users. I particularly like this because it allows me to deploy it as a stateless deployment inside Kubernetes; no external databases or StatefulSets. Big plus in my book.
Objective#
By the end every service I want to protect (each accessible via different subdomains) with either one factor (username and password) or two factor (TOTP) should show the following login page before being able to access it:

Architecture overview#
+------------------------+
| User's Browser |
+-----------+------------+
|
v
HTTPS request to protected service
|
v
+-------------+--------------+
| Traefik Ingress |
| (ingress controller w/ |
| Authelia middleware) |
+-------------+--------------+
|
Is the user authenticated? ───┐
| |
No: forward to Authelia |
| |
v |
+--------------+---------------+
| Authelia |
| (Stateless, Config from |
| template + secret data) |
+--------------+---------------+
|
Login with password or TOTP (2FA) |
|
Auth success → Redirect to service |
| |
v |
+-------------+--------------+
| Protected Service (e.g. |
| Karakeep) |
+----------------------------+
OIDC flow for apps that support it (like Karakeep)
+--------------------+
| Karakeep |
| (OIDC Relying |
| Party) |
+---------+----------+
|
| Redirect to Authelia for login
v
+---------+----------+
| Authelia |
| (OIDC Provider) |
+---------+----------+
|
| Token/claims (via redirect)
v
+---------+----------+
| Karakeep |
| (accepts token and |
| grants access) |
+--------------------+Part 1. Stateless Authelia#
Authelia is deployed in a completely stateless manner using an init container that renders its configuration from a template stored in a ConfigMap. All sensitive data (such as secrets, private keys, and the user database) are stored securely with sops+age.
The directory structure will look as follows:
.
├── config.yaml
├── deployment.yaml
├── ingress.yaml
├── kustomization.yaml
├── middleware.yaml
├── namespace.yaml
├── secret.yaml
└── service.yamlI use Kustomize and Flux to deploy all my manifests and Helm charts. I will briefly touch on my usage of Kustomize here but details about those two things are out of scope for this article. Maybe I’ll expand on the topics in another one.
1.1. Create a namespace#
I started by creating a dedicated namespace for Authelia:
apiVersion: v1
kind: Namespace
metadata:
name: authelia1.2. Secure sensitive data with sops+age#
I use sops+age to manage all secrets (so that raw values never appear in git). Here’s an example manifest for Authelia:
kind: Secret
apiVersion: v1
metadata:
name: authelia
namespace: authelia
type: Opaque
stringData:
JWT_SECRET: ENC[AES256_GCM,data:IhDBfLnivCMifccSBcZsV00t... (truncated)
SESSION_SECRET: ENC[AES256_GCM,data:I6czxP0PLYOo7MRD+G8I... (truncated)
ENCRYPTION_KEY: ENC[AES256_GCM,data:o/N0ueAUyQWDUoL8hPLd... (truncated)
HMAC_SECRET: ENC[AES256_GCM,data:RHvBUpp2lzXvmS2S5VLZC3G... (truncated)
ISSUER_PRIVATE_KEY: ENC[AES256_GCM,data:jIOseYZphoy6UsPY... (truncated)
KARAKEEP_CLIENT_SECRET: ENC[AES256_GCM,data:G98OXfp+dr+/... (truncated)
users.yml: ENC[AES256_GCM,data:UVIZ3hmINQl3/P/L5TmO+YZoL... (truncated)
sops:
age:
- recipient: age166emlwc4zzzdzfjeurrxtk05wnz6dqxdnujr2u6h63c800dutsxstyl7n9
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3dk4xbGFieTFlNTR3a0FI
K0ZScUJUdVVYajR0d3UvL2VjYWp0dU1RazFvCmxsbm01YnV0b29UOURPa0dFVjZ4
aGx4QXJ2azdBN25ka3VwRTJxSmdoYU0KLS0tIE5tblV5NjJXUFlXK24rbmlXeXJV
V3NSeEJKRHB5bXU4bEIwUFdUZkpBWVUKFGwcXUWBaLC/rliC8dcCQ94jbh9wqWu1
Cj4e7J4N4SxZaFe29+UxmznNiwMhLXUQSBNewr98rjcSgQBg7HLzGA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2022-07-17T09:43:17Z"
mac: ENC[AES256_GCM,data:ZHfj... (truncated)
encrypted_regex: ^(data|stringData)$
version: 3.9.21.3. Define the configuration template#
To keep Authelia stateless, I don’t want to mount a writable ConfigMap directly (since ConfigMaps are read‑only). Instead, I store a configuration template in a ConfigMap and use an init container to render the final configuration file.
apiVersion: v1
kind: ConfigMap
metadata:
name: authelia-config
namespace: authelia
data:
configuration.tpl.yml: |
server:
address: 0.0.0.0:9091
log:
level: info
identity_validation:
reset_password:
jwt_secret: ${JWT_SECRET}
authentication_backend:
password_reset:
disable: true
file:
path: /secrets/users.yml
password:
algorithm: argon2id
iterations: 1
salt_length: 16
parallelism: 8
memory: 64
access_control:
default_policy: one_factor
rules:
- domain:
- "efym.net"
- "login.efym.net"
policy: bypass
- domain:
- "*"
policy: one_factor
- domain:
- "adguard1.efym.net"
- "adguard2.efym.net"
subject:
- "group:users"
policy: deny
session:
name: authelia_session
same_site: lax
secret: ${SESSION_SECRET}
expiration: 8h
inactivity: 8h
cookies:
- domain: "efym.net"
authelia_url: "https://login.efym.net"
default_redirection_url: "https://efym.net"
regulation:
max_retries: 5
find_time: 2m
ban_time: 10m
theme: dark
storage:
encryption_key: ${ENCRYPTION_KEY}
local:
path: /config/db.sqlite3
notifier:
filesystem:
filename: /config/notification.txt
identity_providers:
oidc:
claims_policies:
legacy:
id_token:
- email
- email_verified
- name
- preferred_username
- groups
hmac_secret: ${HMAC_SECRET}
issuer_private_key: |
${ISSUER_PRIVATE_KEY}
clients:
- client_id: karakeep
client_name: Karakeep
client_secret: ${KARAKEEP_CLIENT_SECRET}
public: false
authorization_policy: one_factor
require_pkce: false
pkce_challenge_method: ''
redirect_uris:
- 'https://links.efym.net/api/auth/callback/custom'
scopes:
- 'openid'
- 'email'
- 'profile'
response_types:
- 'code'
grant_types:
- 'authorization_code'
claims_policy: 'legacy'
consent_mode: implicit
access_token_signed_response_alg: 'none'
userinfo_signed_response_alg: 'none'
token_endpoint_auth_method: 'client_secret_basic'1.4. Create the Deployment with an Init Container
The Deployment mounts the ConfigMap (read-only) at /config-template and uses an init container (based on a Debian image) to install gettext-base (which provides envsubst) and then render the configuration file into a writable emptyDir volume. The main container then uses that rendered configuration.
apiVersion: apps/v1
kind: Deployment
metadata:
name: authelia
namespace: authelia
labels:
app: authelia
spec:
replicas: 1
selector:
matchLabels:
app: authelia
template:
metadata:
labels:
app: authelia
spec:
enableServiceLinks: false
containers:
- name: authelia
image: authelia/authelia:latest
imagePullPolicy: IfNotPresent
ports:
- containerPort: 9091
volumeMounts:
- name: config
mountPath: /config
- name: secret-volume
mountPath: /secrets
readOnly: true
env:
- name: TZ
value: "Europe/London"
initContainers:
- name: init-config
image: debian:bullseye-slim
command:
- sh
- -c
- |
apt-get update && apt-get install -y gettext-base && \
envsubst < /config-template/configuration.tpl.yml > /config/configuration.yml && \
echo "Rendered configuration:" && cat /config/configuration.yml && \
sleep 5
env:
- name: JWT_SECRET
valueFrom:
secretKeyRef:
name: authelia
key: JWT_SECRET
- name: SESSION_SECRET
valueFrom:
secretKeyRef:
name: authelia
key: SESSION_SECRET
- name: ENCRYPTION_KEY
valueFrom:
secretKeyRef:
name: authelia
key: ENCRYPTION_KEY
- name: HMAC_SECRET
valueFrom:
secretKeyRef:
name: authelia
key: HMAC_SECRET
- name: ISSUER_PRIVATE_KEY
valueFrom:
secretKeyRef:
name: authelia
key: ISSUER_PRIVATE_KEY
- name: KARAKEEP_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: authelia
key: KARAKEEP_CLIENT_SECRET
volumeMounts:
- name: config-template
mountPath: /config-template
- name: config
mountPath: /config
volumes:
- name: config-template
configMap:
name: authelia-config
items:
- key: configuration.tpl.yml
path: configuration.tpl.yml
- name: config
emptyDir: {}
- name: secret-volume
secret:
secretName: autheliaThe users.yml file is stored as a key in the same Kubernetes secret and is automatically mounted at /secrets/users.yml due to the default behavior of secret volumes.
1.5. Create a Service#
apiVersion: v1
kind: Service
metadata:
name: authelia
namespace: authelia
spec:
selector:
app: authelia
ports:
- name: http
port: 9091
targetPort: 9091
protocol: TCP1.6. Aggregate with Kustomize#
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: authelia
resources:
- namespace.yaml
- secret.yaml
- config.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- middleware.yamlThat should be all the needed files. I’ll now apply them with kubectl.
(This is where I would use Flux and GitOps, but like I mentioned before that’s not in scope for now.)
kubectl apply -k .Part 2. Karakeep with SSO as example#
With Authelia set up as an OIDC provider, I needed to configure Karakeep to use OIDC for authentication. In my Authelia configuration, I added an OIDC client with the ID karakeep. Now I’ll enable OIDC in Karakeep by providing the correct environment variables.
2.1. Karakeep Deployment Manifest#
Below is my Karakeep deployment manifest (with persistent storage for data, which you can adjust as needed). The key section is the environment variables that enable OIDC:
apiVersion: apps/v1
kind: Deployment
metadata:
name: karakeep
labels:
app: karakeep
spec:
replicas: 1
selector:
matchLabels:
app: karakeep
template:
metadata:
labels:
app: karakeep
spec:
enableServiceLinks: true
containers:
- name: karakeep
image: ghcr.io/karakeep-app/karakeep:release
ports:
- containerPort: 3000
name: http
env:
- name: TZ
value: "Europe/London"
- name: DATA_DIR
value: "/data"
- name: NEXTAUTH_URL
value: "https://links.efym.net"
- name: MEILI_ADDR
value: "http://meilisearch.meilisearch.svc.cluster.local:7700"
- name: MEILI_MASTER_KEY
valueFrom:
secretKeyRef:
name: karakeep
key: MEILI_MASTER_KEY
- name: NEXTAUTH_SECRET
valueFrom:
secretKeyRef:
name: karakeep
key: NEXTAUTH_SECRET
- name: OPENAI_API_KEY
valueFrom:
secretKeyRef:
name: karakeep
key: OPENAI_API_KEY
- name: OAUTH_WELLKNOWN_URL
value: "https://login.efym.net/.well-known/openid-configuration"
- name: OAUTH_CLIENT_ID
value: "karakeep"
- name: OAUTH_CLIENT_SECRET
valueFrom:
secretKeyRef:
name: karakeep
key: KARAKEEP_CLIENT_SECRET
- name: OAUTH_SCOPE
value: "openid email profile"
- name: OAUTH_PROVIDER_NAME
value: "Authelia"
- name: OAUTH_ALLOW_DANGEROUS_EMAIL_ACCOUNT_LINKING
value: "false"
volumeMounts:
- name: karakeep-data
mountPath: /data
volumes:
- name: karakeep-data
persistentVolumeClaim:
claimName: karakeep-data2.2. Other Karakeep Resources#
PersistentVolumeClaim
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
name: karakeep-data
spec:
accessModes:
- ReadWriteMany
storageClassName: longhorn
resources:
requests:
storage: 5GiService
apiVersion: v1
kind: Service
metadata:
name: karakeep
spec:
selector:
app: karakeep
ports:
- name: http
port: 3000
targetPort: 3000
protocol: TCPIngressRoute (I use an IngressRoute because Traefik is my ingress controller)
apiVersion: traefik.io/v1alpha1
kind: IngressRoute
metadata:
name: karakeep
annotations:
kubernetes.io/ingress.class: traefik-external
spec:
entryPoints:
- websecure
routes:
- match: Host(`links.efym.net`)
kind: Rule
middlewares:
- name: authelia
namespace: authelia
services:
- name: karakeep
port: 3000
tls:
secretName: efym-net-tlssops+age secret for Karakeep
kind: Secret
apiVersion: v1
metadata:
name: karakeep
namespace: karakeep
type: Opaque
stringData:
KARAKEEP_CLIENT_SECRET: <encrypted-data>
MEILI_MASTER_KEY: <encrypted-data>
NEXTAUTH_SECRET: <encrypted-data>
OPENAI_API_KEY: <encrypted-data>
sops:
age:
- recipient: age166emlwc4zzzdzfjeurrxtk05wnz6dqxdnujr2u6h63c800dutsxstyl7n9
enc: |
-----BEGIN AGE ENCRYPTED FILE-----
YWdlLWVuY3J5cHRpb24ub3JnL3YxCi0+IFgyNTUxOSB3dk4xbGFieTFlNTR3a0FI
K0ZScUJUdVVYajR0d3UvL2VjYWp0dU1RazFvCmxsbm01YnV0b29UOURPa0dFVjZ4
aGx4QXJ2azdBN25ka3VwRTJxSmdoYU0KLS0tIE5tblV5NjJXUFlXK24rbmlXeXJV
V3NSeEJKRHB5bXU4bEIwUFdUZkpBWVUKFGwcXUWBaLC/rliC8dcCQ94jbh9wqWu1
Cj4e7J4N4SxZaFe29+UxmznNiwMhLXUQSBNewr98rjcSgQBg7HLzGA==
-----END AGE ENCRYPTED FILE-----
lastmodified: "2022-08-17T11:23:17Z"
mac: ENC[AES256_GCM,data:ZHfj... (truncated)
encrypted_regex: ^(data|stringData)$
version: 3.9.2Kustomization for Karakeep
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
namespace: karakeep
resources:
- namespace.yaml
- pvc.yaml
- secret.yaml
- deployment.yaml
- service.yaml
- ingress.yamlConclusion#
In this article I explained how I deployed Authelia in a stateless manner on Kubernetes using an init container that renders its configuration from a template stored in a ConfigMap. All sensitive values are managed securely with sops+age. Then, I showed how to configure Karakeep to use OIDC for authentication by setting the required environment variables (such as OAUTH_WELLKNOWN_URL, OAUTH_CLIENT_ID, and OAUTH_CLIENT_SECRET).
Authelia can be used through a middleware to protect services or pages which do not rely on their own native authentication, or alternatively used as an OIDC provider to manage SSO if such implementation is supported by the target application.
This setup allowed me to bootstrap a production-ready environment without needing pre-provisioned persistent storage for Authelia’s configuration while keeping secrets secure and integrating with Karakeep via OIDC.
