Self-hosting

Kubernetes

DDM in a Kubernetes cluster – one file with everything, two with your settings, and a secret for the registry.

For Kubernetes there is one file with everything the installation needs – ddm.yaml – and two files with your settings. All three are on this page.

What ddm.yaml creates

ObjectNote
postgres (StatefulSet)With its own volume. If you already run a Postgres database, you can use it instead – see below.
cms (Deployment)Exactly one instance, updated with Recreate: the CMS applies the migrations and runs the scheduled tasks.
render-worker (Deployment)Can grow to several instances.
client (Deployment)The reader app.
ddm-data (PersistentVolumeClaim)Documents and backups, shared by the CMS and the render worker – ReadWriteMany, unless the documents are in an S3 bucket.
ddm (Ingress)Both host names, for ingress-nginx with uploads up to 1 GB.

1. Create the namespace and the registry secret

kubectl create namespace ddm
kubectl -n ddm create secret docker-registry ddm-registry \
  --docker-server=registry.gitlab.com \
  --docker-username=<token name> --docker-password=<token>

2. Create the settings

config.env – your settings, the same as with Docker Compose:

config.env
# The installation's settings, made into the ConfigMap `ddm`:
#     kubectl -n ddm create configmap ddm --from-env-file=config.env
# Every setting of the user guide's „Konfiguration" / „Configuration" may be
# added. Secrets go in `secrets.env`.
DDM_CMS_HOST=cms.example.com
DDM_APP_HOST=app.example.com
AUTH_COOKIE_DOMAIN=example.com
PASSKEY_RP_ID=example.com

MAIL_DRIVER=smtp
MAIL_FROM=ddm@example.com
SMTP_HOST=mail.example.com
SMTP_PORT=587
SMTP_USER=ddm

# The documents in an S3-compatible bucket instead of the `ddm-data` volume
# (its keys in `secrets.env`).
# S3_ENDPOINT=https://s3.example.com
# S3_BUCKET=ddm
# S3_REGION=eu-central-1

secrets.env – passwords, BETTER_AUTH_SECRET and LICENCE_KEY. This file belongs in no version control – or only encrypted, for example with SOPS.

secrets.env
# The installation's secrets, made into the Secret `ddm`:
#     kubectl -n ddm create secret generic ddm --from-env-file=secrets.env
# Copy this file to `secrets.env`, fill it in, and keep it out of version
# control. `openssl rand -base64 32` for each. The database's password is kept by
# Postgres on its first start; changing it here later locks the cms out.
POSTGRES_PASSWORD=
BETTER_AUTH_SECRET=
SMTP_PASSWORD=
# The licence we sent you (`ddm1.…`) — needed from the first start; one
# entered later under Settings › Licence replaces it.
LICENCE_KEY=
# S3_ACCESS_KEY_ID=
# S3_SECRET_ACCESS_KEY=

They become the ConfigMap and the Secret ddm:

kubectl -n ddm create configmap ddm --from-env-file=config.env
kubectl -n ddm create secret generic ddm --from-env-file=secrets.env

3. Adapt ddm.yaml

Save the file as ddm.yaml and change two things in it:

  • The version: :latest for cms, render-worker and client becomes the version you run – for example :0.26. Postgres stays at :18.
  • Your host names: cms.example.com and app.example.com in the Ingress at the end of the file.
ddm.yaml
# DDM on Kubernetes — everything an installation runs, in one file:
#
#     kubectl create namespace ddm
#     kubectl -n ddm create secret docker-registry ddm-registry \
#       --docker-server=registry.gitlab.com --docker-username=<token name> --docker-password=<token>
#     kubectl -n ddm create configmap ddm --from-env-file=config.env
#     kubectl -n ddm create secret generic ddm --from-env-file=secrets.env
#     kubectl -n ddm apply -f ddm.yaml
#
# Before applying, set in this file:
#   - the release, the same for cms, render-worker and client: `:latest`
#     below, e.g. `:0.26` (Postgres stays at its major, 18). The comment
#     behind each of those lines is where Flux writes a new version, when it
#     updates the installation (`ddm-updates.yaml`); without Flux it does nothing;
#   - your two hostnames, for `cms.example.com` and `app.example.com` in the
#     Ingress at the end.
#
# The settings are in the ConfigMap `ddm` and the Secret `ddm`, made from
# `config.env` and `secrets.env`. After changing them, restart the apps:
#     kubectl -n ddm rollout restart deployment
#
# The cms runs once, and is replaced, not rolled: its scheduled tasks and the
# migrations it runs on start are one process's. The documents are on a
# volume the cms and the render worker share (ReadWriteMany), unless the
# configuration names an S3 bucket.

---
# Postgres with the German dictionary DDM searches with, by its major — never
# updated with the rest: a new major needs its data upgraded by hand.
apiVersion: v1
kind: Service
metadata:
  name: postgres
spec:
  selector:
    app.kubernetes.io/name: postgres
  ports:
    - name: postgres
      port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: postgres
spec:
  serviceName: postgres
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: postgres
  template:
    metadata:
      labels:
        app.kubernetes.io/name: postgres
    spec:
      imagePullSecrets:
        - name: ddm-registry
      # As the image's `postgres` user (70) from the start — no root to drop
      # from — the volume made its group's.
      securityContext:
        runAsNonRoot: true
        runAsUser: 70
        runAsGroup: 70
        fsGroup: 70
        seccompProfile:
          type: RuntimeDefault
      containers:
        - name: postgres
          image: registry.gitlab.com/iq-me/new-ddm-images/postgres:18
          # Postgres' own fixes come under the same tag: a restart brings them (user guide, „Postgres").
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          env:
            - name: POSTGRES_USER
              value: ddm
            - name: POSTGRES_DB
              value: ddm
            - name: POSTGRES_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: ddm
                  key: POSTGRES_PASSWORD
          ports:
            - name: postgres
              containerPort: 5432
          readinessProbe:
            exec:
              command: [pg_isready, -U, ddm, -d, ddm]
            periodSeconds: 5
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 2Gi
          volumeMounts:
            - name: postgres
              mountPath: /var/lib/postgresql
            # Writable besides its data: the socket, and scratch.
            - name: run
              mountPath: /var/run/postgresql
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: run
          emptyDir: {}
        - name: tmp
          emptyDir: {}
  volumeClaimTemplates:
    - metadata:
        name: postgres
      spec:
        accessModes: [ReadWriteOnce]
        resources:
          requests:
            storage: 20Gi
---
# The documents, and the database's dumps taken before each migration (the
# last three): read and written by the cms and the render worker, which may
# run on different nodes. With an S3 bucket configured (`S3_ENDPOINT`) nothing
# is kept here, and its size may be small.
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: ddm-data
spec:
  accessModes: [ReadWriteMany]
  resources:
    requests:
      storage: 100Gi
---
# The management app, and the API the reader app, the render worker's
# documents and every integration go through. One replica, replaced rather
# than rolled: it runs the scheduled tasks, and migrates the database to its
# release before it answers.
apiVersion: v1
kind: Service
metadata:
  name: cms
spec:
  selector:
    app.kubernetes.io/name: cms
  ports:
    - name: http
      port: 3000
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: cms
spec:
  replicas: 1
  strategy:
    type: Recreate
  selector:
    matchLabels:
      app.kubernetes.io/name: cms
  template:
    metadata:
      labels:
        app.kubernetes.io/name: cms
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      containers:
        - name: cms
          image: registry.gitlab.com/iq-me/new-ddm-images/cms:latest # {"$imagepolicy": "flux-system:ddm-cms"}
          # Asked anew at every start: a tag that moves (`stable`, `0.26`) brings its newest image.
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
            - secretRef:
                name: ddm
          env:
            # Which version of this file this is: a release that needs a newer
            # one says so to the administrators, rather than failing (user guide, „Updates").
            - name: DDM_DEPLOY_VERSION
              value: "1"
            - name: DATABASE_URL
              value: postgres://ddm:$(POSTGRES_PASSWORD)@postgres:5432/ddm
            - name: BETTER_AUTH_URL
              value: https://$(DDM_CMS_HOST)
            - name: CLIENT_ORIGIN
              value: https://$(DDM_APP_HOST)
            - name: STORAGE_DIR
              value: /data/storage
            - name: BACKUP_DIR
              value: /data/backups
            # Uploads are stored here and drawn by the render worker.
            - name: RENDER_MODE
              value: worker
          ports:
            - name: http
              containerPort: 3000
          # Migrations first, on a new release: up to five minutes.
          startupProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 2
            failureThreshold: 150
          readinessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 30
            timeoutSeconds: 5
          resources:
            requests:
              cpu: 250m
              memory: 512Mi
            limits:
              memory: 2Gi
          volumeMounts:
            - name: data
              mountPath: /data
            # The one place written besides `/data`: uploads on their way, LibreOffice's profiles.
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: ddm-data
        - name: tmp
          emptyDir: {}
---
# Reads the text, draws the thumbnails and converts the Office files of what
# is uploaded, sharing the work through the database: more replicas for more
# imports. Each finishes the documents in hand before it stops — LibreOffice
# may take minutes over a large deck.
apiVersion: apps/v1
kind: Deployment
metadata:
  name: render-worker
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: render-worker
  template:
    metadata:
      labels:
        app.kubernetes.io/name: render-worker
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
        fsGroup: 1000
      terminationGracePeriodSeconds: 660
      containers:
        - name: render-worker
          image: registry.gitlab.com/iq-me/new-ddm-images/render-worker:latest # {"$imagepolicy": "flux-system:ddm-render-worker"}
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
            - secretRef:
                name: ddm
          env:
            - name: DATABASE_URL
              value: postgres://ddm:$(POSTGRES_PASSWORD)@postgres:5432/ddm
            - name: STORAGE_DIR
              value: /data/storage
            - name: RENDER_LARGE_OFFICE
              value: "on"
          resources:
            requests:
              cpu: 500m
              memory: 1Gi
            limits:
              memory: 3Gi
          volumeMounts:
            - name: data
              mountPath: /data
            # The one place written besides `/data`: uploads on their way, LibreOffice's profiles.
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: data
          persistentVolumeClaim:
            claimName: ddm-data
        - name: tmp
          emptyDir: {}
---
# The reader app: its pages, and everything under /api forwarded to the cms.
apiVersion: v1
kind: Service
metadata:
  name: client
spec:
  selector:
    app.kubernetes.io/name: client
  ports:
    - name: http
      port: 3000
      targetPort: http
---
apiVersion: apps/v1
kind: Deployment
metadata:
  name: client
spec:
  replicas: 1
  selector:
    matchLabels:
      app.kubernetes.io/name: client
  template:
    metadata:
      labels:
        app.kubernetes.io/name: client
    spec:
      imagePullSecrets:
        - name: ddm-registry
      securityContext:
        runAsNonRoot: true
        runAsUser: 1000
        runAsGroup: 1000
      containers:
        - name: client
          image: registry.gitlab.com/iq-me/new-ddm-images/client:latest # {"$imagepolicy": "flux-system:ddm-client"}
          imagePullPolicy: Always
          securityContext:
            allowPrivilegeEscalation: false
            readOnlyRootFilesystem: true
            capabilities:
              drop: [ALL]
          envFrom:
            - configMapRef:
                name: ddm
          env:
            - name: NUXT_API_ORIGIN
              value: http://cms:3000
            - name: NUXT_PUBLIC_MGMT_URL
              value: https://$(DDM_CMS_HOST)
            # The reader app on this installation, for the mobile app's „Server".
            - name: NUXT_PUBLIC_SERVER_URL
              value: https://$(DDM_APP_HOST)
            - name: RENDER_MODE
              value: worker
          ports:
            - name: http
              containerPort: 3000
          # Through to the cms: what the reader app forwards answers, too.
          readinessProbe:
            httpGet:
              path: /api/auth/ok
              port: http
            periodSeconds: 10
          resources:
            requests:
              cpu: 100m
              memory: 128Mi
            limits:
              memory: 512Mi
          volumeMounts:
            - name: tmp
              mountPath: /tmp
      volumes:
        - name: tmp
          emptyDir: {}
---
# Both hostnames — replace `cms.example.com` and `app.example.com` with yours —
# with TLS from the Secret `ddm-tls`, or from cert-manager with its
# annotation added here. Uploads are up to 1 GiB and may take a while:
# ingress-nginx's limits raised; another controller needs its own.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: ddm
  annotations:
    nginx.ingress.kubernetes.io/proxy-body-size: 1g
    nginx.ingress.kubernetes.io/proxy-read-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-send-timeout: "600"
    nginx.ingress.kubernetes.io/proxy-request-buffering: "off"
spec:
  tls:
    - hosts: [cms.example.com, app.example.com]
      secretName: ddm-tls
  rules:
    - host: cms.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: cms
                port:
                  name: http
    - host: app.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: client
                port:
                  name: http

4. Apply

kubectl -n ddm apply -f ddm.yaml
kubectl -n ddm rollout status deployment/cms

Then, as with Docker Compose, create the first account.

Changing settings

After changing config.env or secrets.env, write the ConfigMap and the Secret again and restart the apps:

kubectl -n ddm create configmap ddm --from-env-file=config.env --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ddm create secret generic ddm --from-env-file=secrets.env --dry-run=client -o yaml | kubectl apply -f -
kubectl -n ddm rollout restart deployment

Using your own database

If you already run Postgres yourself, DDM needs Postgres 18 with the German dictionary for full-text search, which our postgres:18 image brings along. Then:

  1. Enter the address in secrets.env: EXTERNAL_DATABASE_URL=postgres://ddm:<password>@db.company.com:5432/ddm.
  2. In ddm.yaml, replace the value of DATABASE_URL for cms and render-worker with $(EXTERNAL_DATABASE_URL).
  3. In ddm.yaml, delete the first two sections – the Service and the StatefulSet postgres.

Without ReadWriteMany

The CMS and the render worker share the volume ddm-data. If your cluster cannot provide a ReadWriteMany volume, put the documents in an S3-compatible bucket – S3_ENDPOINT and S3_BUCKET in config.env, the keys in secrets.env. The backups then go to the same bucket. After that, only the CMS needs the volume. Change in ddm.yaml:

  1. for the PersistentVolumeClaim ddm-data, the accessModes to [ReadWriteOnce],
  2. for the Deployment render-worker, the data entries under volumeMounts and under volumes – delete both; tmp stays.