Kubernetes¶
This page deploys Adriano to Kubernetes from the example manifests
in deploy/kubernetes/ of the release, using kubectl and
kustomize.
The manifests are an example to adapt, not a Helm chart.
Their YAML is checked against the Kubernetes API schemas, but they
have not been run against a particular cluster or ingress controller.
Read Deploying Adriano first; it explains what you deploy, how to register the identity provider client and which image tags exist.
Prerequisites¶
A Kubernetes cluster,
kubectlwith the built-in kustomize (kubectl apply -k) and an ingress controller.A PostgreSQL database reachable from the cluster, with TLS.
The things listed in Deploying Adriano: an identity provider client and a record source.
A TLS certificate for the host name, as a
kubernetes.io/tlsSecret, or cert-manager to create it.
The manifests contain no namespace.
Create one and pass it to every kubectl command:
kubectl create namespace adriano
kubectl config set-context --current --namespace adriano
What the manifests create¶
secret.yaml,configmap.yamlThe configuration (Configuration).
deployment.yamlThe web UI and API: two replicas and an init container that applies database migrations.
service.yaml,ingress.yamlThe cluster-internal address and the public TLS entry point.
cronjob.yamlThe poll schedule.
poddisruptionbudget.yamlKeeps one pod serving during node drains.
kustomization.yamlTies them together and pins the image version.
Configuration¶
Secret¶
secret.yaml holds only placeholders.
Replace them with your values, or better, create the Secret named
adriano from an external secret manager or with Sealed Secrets,
so real values never sit in Git.
It needs these keys:
# PLACEHOLDERS ONLY. Replace every value before applying, and do not
# commit real values. Prefer an external secret manager (External
# Secrets Operator, Vault, a cloud secret store) or Sealed Secrets, so
# the real values never live in Git; this file then only documents
# which keys the Secret must contain.
apiVersion: v1
kind: Secret
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: config
app.kubernetes.io/part-of: adriano
type: Opaque
stringData:
# Required. Plain postgresql:// DSN. Keep sslmode=require (or
# verify-full) for a database outside the cluster.
ADRIANO_DATABASE_URL: "postgresql://adriano:CHANGE-ME@postgres.example.com:5432/adriano?sslmode=require"
# Secrets that catalog.yaml refers to by NAME (token_env and
# push_tokens_env). Keep these names in step with your catalog.
# Only needed for a gitlab source (token_env).
ADRIANO_GITLAB_TOKEN_PLATFORM: "CHANGE-ME-gitlab-access-token"
ADRIANO_PUSH_TOKEN_PLATFORM: "CHANGE-ME-long-random-token"
Keep
sslmode=require(orverify-full) in the database address when the database is outside the cluster.Add one key for every secret your catalog file names (Secrets named by the catalog file). The Secret is used by the Deployment and by the poll job, so source access tokens and push tokens are available to both.
ConfigMap¶
configmap.yaml holds two ConfigMaps: the non-secret settings
(adriano) and the catalog file (adriano-catalog).
Set your identity provider values and replace the catalog with your
own (The catalog file).
# Non-secret settings (ConfigMap `adriano`) and the catalog file
# (ConfigMap `adriano-catalog`). Adjust the OIDC values and the
# catalog for your deployment. Changing either ConfigMap does not
# restart running pods: run
# kubectl rollout restart deployment/adriano
apiVersion: v1
kind: ConfigMap
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: config
app.kubernetes.io/part-of: adriano
data:
# Your identity provider (see docs/operator/authentication.rst).
ADRIANO_OIDC_AUTHORITY: "https://idp.example.com/realms/adr"
ADRIANO_OIDC_CLIENT_ID: "adriano-web"
ADRIANO_OIDC_AUDIENCE: "adr_viewer"
ADRIANO_OIDC_ROLES_CLAIM: "realm_access.roles"
# Where the catalog (ConfigMap adriano-catalog) is mounted (see
# deployment.yaml).
ADRIANO_CONFIG_FILE: "/etc/adriano/catalog.yaml"
# Optional: trust an internal CA in addition to the public ones.
# Uncomment together with the `ca` volume and mounts in
# deployment.yaml and cronjob.yaml.
# ADRIANO_EXTRA_CA_CERTS: "/etc/adriano-ca"
# Trust X-Forwarded-* from the ingress controller. Pod addresses of
# the controller are not stable, so every peer is trusted: only
# allow the ingress controller to reach the Service (NetworkPolicy).
ADRIANO_FORWARDED_ALLOW_IPS: "*"
# The migrate init container applies migrations; the serving pods
# do not repeat them.
ADRIANO_MIGRATE_ON_START: "false"
# Worker processes per pod. Memory use scales with it (see the
# resources in deployment.yaml).
ADRIANO_WORKERS: "2"
---
apiVersion: v1
kind: ConfigMap
metadata:
name: adriano-catalog
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: config
app.kubernetes.io/part-of: adriano
data:
catalog.yaml: |
# Minimal example: one source (a GitLab group), a review policy
# and two roles. See docs/operator/catalog-file.rst for the reference.
sources:
- id: gitlab-platform
type: gitlab
base_url: https://gitlab.example.com
token_env: ADRIANO_GITLAB_TOKEN_PLATFORM
scope:
groups: [platform]
path: doc/decisions
published_ref: "@project-default"
intake: [poll, push]
push_tokens_env: [ADRIANO_PUSH_TOKEN_PLATFORM]
# Or a folder of records mounted into the pod (no access token
# needed; add a volume mount for it to the Deployment and the
# CronJob, and the push token to the Secret):
# - id: central
# type: filesystem
# root: /srv/adr-root
# scope:
# projects: [platform]
# path: doc/decisions
# published_ref: local
# intake: [poll, push]
# push_tokens_env: [ADRIANO_PUSH_TOKEN_CENTRAL]
approval_policy:
id: global
version: 1
required: true
reviewers:
users: []
roles: [adr-reviewer]
allow_self_approval: false
rbac:
roles:
admin: [read, operate]
adr-reader: [read]
ADRIANO_FORWARDED_ALLOW_IPS is * because the addresses of
ingress controller pods change.
Adriano then trusts the X-Forwarded-* headers of every peer, so
let only the ingress controller reach the Service, for example with
a NetworkPolicy.
If your controller has fixed addresses, list them instead.
Changes to a ConfigMap or Secret reach the pods only when they restart:
kubectl rollout restart deployment/adriano
Validate the catalog before you apply it.
Save its content as catalog.yaml and run:
docker run --rm \
-e ADRIANO_CONFIG_FILE=/c/catalog.yaml \
-e ADRIANO_DATABASE_URL=postgresql://x:x@localhost/x \
-v "$PWD:/c:ro" ghcr.io/exhuma/adriano:<version> \
adriano check-config
Deployment¶
apiVersion: apps/v1
kind: Deployment
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
app.kubernetes.io/part-of: adriano
spec:
replicas: 2
selector:
matchLabels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 0
maxSurge: 1
template:
metadata:
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
app.kubernetes.io/part-of: adriano
spec:
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
initContainers:
# Applies pending database migrations before the new pods
# serve. Safe with several replicas: concurrent runs wait on
# a database lock.
- name: migrate
image: ghcr.io/exhuma/adriano
command: ["adriano", "migrate"]
envFrom:
- configMapRef:
name: adriano
- secretRef:
name: adriano
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
# Optional internal CA (see ADRIANO_EXTRA_CA_CERTS in
# configmap.yaml). The init container needs it too: the
# path must exist in every container.
# - name: ca
# mountPath: /etc/adriano-ca
# readOnly: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
containers:
- name: adriano
image: ghcr.io/exhuma/adriano
# The image command is `adriano serve`.
ports:
- name: http
containerPort: 8000
envFrom:
- configMapRef:
name: adriano
- secretRef:
name: adriano
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: catalog
mountPath: /etc/adriano
readOnly: true
# Optional internal CA, as in the migrate init container.
# - name: ca
# mountPath: /etc/adriano-ca
# readOnly: true
# Memory grows with ADRIANO_WORKERS: budget roughly 100 to
# 150 MiB per worker plus a base. Raise the limit together
# with the worker count.
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
memory: 512Mi
# Start-up and liveness only check that the process
# accepts connections. /healthz answers 503 while the
# database is unreachable; a liveness check on it would
# restart every pod during a database outage, which fixes
# nothing. Readiness uses /healthz, so pods without a
# database leave the Service instead.
startupProbe:
tcpSocket:
port: http
periodSeconds: 2
failureThreshold: 30
livenessProbe:
tcpSocket:
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
readinessProbe:
httpGet:
path: /healthz
port: http
periodSeconds: 10
timeoutSeconds: 3
failureThreshold: 3
volumes:
- name: tmp
emptyDir: {}
- name: catalog
configMap:
name: adriano-catalog
# Optional internal CA: a ConfigMap with the PEM file(s), e.g.
# kubectl create configmap adriano-ca --from-file=internal-ca.pem
# - name: ca
# configMap:
# name: adriano-ca
- Migrations
The
migrateinit container runsadriano migratewith the same image and settings before each new pod serves, andADRIANO_MIGRATE_ON_STARTisfalseso the serving containers do not repeat it. Several pods starting together are safe: only one applies the migrations while the others wait. Leave the setting at its defaulttrueand drop the init container if you prefer migrate-on-start; the lock makes that safe as well, but a failing migration then shows up as a crash loop of the serving pods instead of a failed init container.- Probes
Start-up and liveness probes only open the port. Readiness calls
/healthz, which answers HTTP 503 while the database is unreachable. A database outage therefore takes the pods out of the Service without restarting them; a liveness probe on/healthzwould restart every pod during the outage and fix nothing. The catalog and sign-in state in the response body never fail a probe; alert on those (Health checks and logs).- Resources
Each worker process (
ADRIANO_WORKERS) needs roughly 100 to 150 MiB of memory, plus a base. The example runs two workers in a pod limited to 512 MiB. When you raise the worker count, raise the memory limit with it, and keep replicas times workers within your database’s connection limit.- Security
The pod runs as the image’s non-root user (10001) with a read-only root filesystem, no capabilities and the default seccomp profile. Only
/tmpis writable. The catalog, and an optional internal CA, are mounted read-only.
Service and ingress¶
apiVersion: v1
kind: Service
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
app.kubernetes.io/part-of: adriano
spec:
type: ClusterIP
selector:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
ports:
- name: http
port: 80
targetPort: http
# Terminates TLS and forwards to the Service. Replace the class, the
# host and the TLS Secret (a kubernetes.io/tls Secret, or let
# cert-manager create it). The browser origin must match the redirect
# URIs registered at your identity provider.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
app.kubernetes.io/part-of: adriano
spec:
ingressClassName: CHANGE-ME-ingress-class
tls:
- hosts:
- adriano.example.com
secretName: adriano-tls
rules:
- host: adriano.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: adriano
port:
name: http
Set the ingress class, the host and the TLS Secret. The host must match the redirect URIs you registered at the identity provider. Terminate TLS at the ingress; Adriano speaks plain HTTP. Your controller may need annotations of its own, for example for a larger request body when CI pushes large records (the limit of a push is 1 MiB of content).
Poll schedule¶
# Polls the registered sources every 15 minutes. A run that is still
# going when the next one is due makes the next one skip.
apiVersion: batch/v1
kind: CronJob
metadata:
name: adriano-poll
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: poll
app.kubernetes.io/part-of: adriano
spec:
schedule: "*/15 * * * *"
concurrencyPolicy: Forbid
startingDeadlineSeconds: 300
successfulJobsHistoryLimit: 3
failedJobsHistoryLimit: 3
jobTemplate:
spec:
backoffLimit: 1
activeDeadlineSeconds: 900
ttlSecondsAfterFinished: 86400
template:
metadata:
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: poll
app.kubernetes.io/part-of: adriano
spec:
restartPolicy: Never
securityContext:
runAsNonRoot: true
runAsUser: 10001
runAsGroup: 10001
seccompProfile:
type: RuntimeDefault
containers:
- name: poll
image: ghcr.io/exhuma/adriano
command: ["adriano", "poll"]
envFrom:
- configMapRef:
name: adriano
- secretRef:
name: adriano
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
volumeMounts:
- name: tmp
mountPath: /tmp
- name: catalog
mountPath: /etc/adriano
readOnly: true
# Optional internal CA (see ADRIANO_EXTRA_CA_CERTS in
# configmap.yaml).
# - name: ca
# mountPath: /etc/adriano-ca
# readOnly: true
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 512Mi
volumes:
- name: tmp
emptyDir: {}
- name: catalog
configMap:
name: adriano-catalog
# Optional internal CA: the ConfigMap adriano-ca.
# - name: ca
# configMap:
# name: adriano-ca
The CronJob runs adriano poll with the same image,
settings, secrets and catalog as the Deployment.
concurrencyPolicy: Forbid stops a slow poll from being
overlapped, and finished jobs are cleaned up after a day.
A job that exits non-zero (a registration is partial or
failed) is marked failed; alert on failed jobs.
Run it once on demand with:
kubectl create job --from=cronjob/adriano-poll adriano-poll-now
Set ADRIANO_STALE_AFTER_MINUTES to a few poll intervals.
Schedule polls once, not once per replica.
Pod disruption budget and kustomization¶
# Keeps at least one pod serving during node drains.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: adriano
labels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
app.kubernetes.io/part-of: adriano
spec:
minAvailable: 1
selector:
matchLabels:
app.kubernetes.io/name: adriano
app.kubernetes.io/component: web
# Example kustomization for adriano. Apply with:
# kubectl apply -k deploy/kubernetes -n <namespace>
# (see docs/operator/deploy/kubernetes.rst). The manifests carry no
# namespace; the one you pass to kubectl is used.
apiVersion: kustomize.config.k8s.io/v1beta1
kind: Kustomization
resources:
- secret.yaml
- configmap.yaml
- deployment.yaml
- service.yaml
- ingress.yaml
- cronjob.yaml
- poddisruptionbudget.yaml
# Pin the image to one full release version. REPLACE 2026.1.1 with the
# release you deploy, and never use a moving channel tag (stable,
# beta, alpha, edge) in production. This one setting changes the
# Deployment, its migration init container and the poll CronJob
# together, so they can never run different versions.
images:
- name: ghcr.io/exhuma/adriano
newTag: "2026.1.1"
The images entry sets the version of the Deployment, its init
container and the poll job in one place.
Replace the placeholder with the release you deploy.
Use a full version, not a channel such as stable: a channel tag
moves, so a pod restart could silently run a newer release whose
database changes cannot be undone.
Bring-up¶
Edit the manifests as described above.
Apply everything:
kubectl apply -k deploy/kubernetes
Wait for the rollout:
kubectl rollout status deployment/adriano
If the pods stay in
Init, read the migration log:kubectl logs deployment/adriano -c migrate.Run the first poll, so the catalog has content:
kubectl create job --from=cronjob/adriano-poll adriano-poll-first kubectl logs job/adriano-poll-first
See Polling sources for what
partialandfailedmean.If CI should push draft records, hand out the push tokens (Pushing candidates from CI).
Verification¶
Pods are ready and the poll job exists:
kubectl get pods,cronjob
The health endpoint answers:
curl -fsS https://adriano.example.com/healthz
A healthy production deployment answers:
{"status": "ok", "database": "ok", "config": "ok", "policy": "configured", "registrations": "synced", "rbac": "configured", "auth": "oidc", "version": "2026.10.1"}
versionis the release you pinned.authmust beoidc; a value containingdevmeans the development sign-in is active and must be fixed (Authentication and access).Open the web UI and sign in with a user that has the
readpermission.Check the catalog file inside a running pod:
kubectl exec deployment/adriano -- adriano check-config
Upgrade and rollback¶
An upgrade may change the database schema, and a schema change cannot be undone by going back to the old image. Back up the database first (Backup, restore and retention). Then:
Change
newTaginkustomization.yamlto the new version.kubectl apply -k deploy/kubernetes. The init container applies the migrations, then the rolling update replaces the pods one by one. Pods of the old release keep serving until the new ones are ready, so run a release that changes the schema in a maintenance window: the old release may not work against the new schema.Verify as above.
To roll back, set newTag back and apply again, or run
kubectl rollout undo deployment/adriano (the poll job keeps the
new version until you apply the old tag).
That works only while the release you left did not change the
schema.
Migrations only go forward: if the release did change it, restore the
backup taken before the upgrade (Backup, restore and retention) and then
deploy the previous version.
Upgrading has the details.
Several replicas¶
Adriano keeps no state between requests, so you can raise
replicas freely; see Running several replicas for what to watch.