Kubernetes
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
| Object | Note |
|---|---|
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:
# 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.
# 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:
:latestforcms,render-workerandclientbecomes the version you run – for example:0.26. Postgres stays at:18. - Your host names:
cms.example.comandapp.example.comin the Ingress at the end of the file.
# 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:
- Enter the address in
secrets.env:EXTERNAL_DATABASE_URL=postgres://ddm:<password>@db.company.com:5432/ddm. - In
ddm.yaml, replace the value ofDATABASE_URLforcmsandrender-workerwith$(EXTERNAL_DATABASE_URL). - In
ddm.yaml, delete the first two sections – the Service and the StatefulSetpostgres.
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:
- for the PersistentVolumeClaim
ddm-data, theaccessModesto[ReadWriteOnce], - for the Deployment
render-worker, thedataentries undervolumeMountsand undervolumes– delete both;tmpstays.