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, kubectl with 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/tls Secret, 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.yaml

The configuration (Configuration).

deployment.yaml

The web UI and API: two replicas and an init container that applies database migrations.

service.yaml, ingress.yaml

The cluster-internal address and the public TLS entry point.

cronjob.yaml

The poll schedule.

poddisruptionbudget.yaml

Keeps one pod serving during node drains.

kustomization.yaml

Ties 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 (or verify-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

Trusting an internal certificate authority

If your identity provider or GitLab uses a certificate from an internal certificate authority, give Adriano the authority’s certificate. Adriano then trusts it in addition to the public authorities. The certificate is public, so a ConfigMap is fine:

kubectl create configmap adriano-ca --from-file=internal-ca.pem

Then uncomment the optional parts in the manifests:

  • ADRIANO_EXTRA_CA_CERTS in configmap.yaml.

  • The ca volume and its mount in deployment.yaml, in both the migrate init container and the adriano container. A configured path that is not mounted stops a container at start.

  • The ca volume and its mount in cronjob.yaml.

See ADRIANO_EXTRA_CA_CERTS. Do not turn off verification with ADRIANO_TLS_VERIFY in production.

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 migrate init container runs adriano migrate with the same image and settings before each new pod serves, and ADRIANO_MIGRATE_ON_START is false so 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 default true and 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 /healthz would 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 /tmp is 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

  1. Edit the manifests as described above.

  2. Apply everything:

    kubectl apply -k deploy/kubernetes
    
  3. 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.

  4. 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 partial and failed mean.

  5. If CI should push draft records, hand out the push tokens (Pushing candidates from CI).

Verification

  1. Pods are ready and the poll job exists:

    kubectl get pods,cronjob
    
  2. 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"}
    

    version is the release you pinned. auth must be oidc; a value containing dev means the development sign-in is active and must be fixed (Authentication and access).

  3. Open the web UI and sign in with a user that has the read permission.

  4. 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:

  1. Change newTag in kustomization.yaml to the new version.

  2. 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.

  3. 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.