> ## Content Index
> Fetch the complete content index at: https://prod-0-dol-blog-zrh1.dol.ch/llms.txt
> Use this file to discover other available public pages before exploring further.

# Mastodon 4.6 auf Kubernetes installieren
- URL: https://prod-0-dol-blog-zrh1.dol.ch/blog/mastodon-4-6-kubernetes/
- Published: 2026-07-28T00:00:00.000Z
- Updated: 2026-09-10T10:08:09.000Z
- Description: Vollständige Anleitung für eine eigene Instanz: Postgres 18 über einen Operator statt als Deployment, Valkey 9 mit Persistenz, Sidekiq mit Autoscaler, Envoy Gateway statt des eingestellten ingress-nginx, verschlüsselte Secrets und Backups mit Point-in-Time-Recovery. Zweiter Anlauf, diesmal mit den L
- Author: dima
- Tags: de, Anleitung · Kubernetes, #pair-mastodon-4-6-kubernetes

Im September 2025 habe ich schon einmal aufgeschrieben, wie man Mastodon auf Kubernetes installiert. Den Blog von damals gibt es nicht mehr, die alte Anleitung überlebt nur [im Webarchiv](https://web.archive.org/web/20251107070914/https://www.dol.ch/how-to-install-mastodon-4-4-5-on-kubernetes/). Ein Text über Backups, von dem nur noch eine fremde Kopie existiert, bleibt damit wenigstens beim Thema. Meine eigene Instanz lief damit bis in den März 2026, dann habe ich Mastodon aus Kostengründen von Kubernetes heruntergeholt. Fast alles aus der alten Anleitung hat sich in dieser Zeit gehalten. Ein Teil war trotzdem falsch, ausgerechnet der wichtigste: Postgres als `Deployment` mit einem PVC daneben.

Das hier ist die vollständige Anleitung neu geschrieben: die Konfiguration, wie sie zuletzt auf dieser Instanz lief, mit den Versionsständen von heute. Fertig, im Betrieb erprobt, und der Grund, warum ich weiss, wo die scharfen Kanten sitzen. Du musst die alte Anleitung nicht kennen, hier steht alles vom leeren Namespace bis zum geprüften Backup. Was sich geändert hat, erkläre ich jeweils dort, wo die Entscheidung fällt.

Wie beim letzten Mal: kein Helm, alles von Hand, weil ich verstehen will, was läuft. Bei mir lief das als GitOps mit ArgoCD, aber alle Dateien funktionieren genauso mit `kubectl apply`.

## Mein Setup

Drei Nodes mit je 4 GB RAM, 2 Kernen und 20 GB SSD, dazu ein Load Balancer. Geschrieben ist die Anleitung gegen Kubernetes 1.36\. Die persistenten Volumes kommen als Netzwerk-Volumes über die Storage-Klasse des Anbieters, die lokale SSD hält nur System und Images. Envoy Gateway für den Verkehr, cert-manager für die Zertifikate, kubeseal für Secrets. Kein Elasticsearch, das lohnt sich auf dieser Grösse nicht. Warum der nginx-Ingress von 2025 weg ist, erkläre ich weiter unten.

Die Versionen in dieser Anleitung:

| Komponente     | Version | Anmerkung                         |
| -------------- | ------- | --------------------------------- |
| Mastodon       | v4.6.4  | verlangt Postgres 14+, Redis 7.0+ |
| PostgreSQL     | 18.4    | über CloudNativePG                |
| CloudNativePG  | 1.30.0  | der Operator                      |
| Valkey         | 9.1.1   | Redis-Fork, protokollkompatibel   |
| Sealed Secrets | v0.38.4 | Controller und CLI                |
| cert-manager   | v1.21.0 | für Let's Encrypt                 |
| Envoy Gateway  | v1.8.3  | bringt Gateway API 1.5.1 mit      |

Valkey statt Redis ist eine bewusste Entscheidung. Mastodon dokumentiert Redis ab 7.0, und [Valkey](https://valkey.io/?ref=prod-0-dol-blog-zrh1.dol.ch) ist ein protokollkompatibler Fork davon. Die Umgebungsvariablen heissen weiterhin `REDIS_*`, es ändert sich nur das Image.

## Werkzeuge

Für die Secrets brauchst du kubeseal lokal. Unter macOS:

```bash
brew install kubeseal

```

Unter Linux:

```bash
export KUBESEAL_VERSION="0.38.4"
curl -OL "https://github.com/bitnami-labs/sealed-secrets/releases/download/v${KUBESEAL_VERSION}/kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz"
tar -xvzf kubeseal-${KUBESEAL_VERSION}-linux-amd64.tar.gz kubeseal
sudo install -m 755 kubeseal /usr/local/bin/kubeseal

```

Prüfen mit `kubeseal --version`. Ausserdem brauchst du lokal Docker oder Podman: Die Mastodon-Schlüssel weiter unten entstehen in einem Wegwerf-Container.

## Namespace und Controller

```bash
kubectl create namespace mastodon

```

Den Sealed-Secrets-Controller, falls noch nicht vorhanden:

```bash
kubectl apply -f https://github.com/bitnami-labs/sealed-secrets/releases/download/v0.38.4/controller.yaml

```

Dieses Manifest legt den Controller als `sealed-secrets-controller` in `kube-system` ab, und genau davon gehen die `kubeseal`\-Aufrufe weiter unten aus. Läuft dein Controller woanders, etwa aus einem Helm-Chart in einem eigenen Namespace, passe dort `--controller-namespace` und `--controller-name` an.

cert-manager, falls er noch nicht läuft:

```bash
kubectl apply -f https://github.com/cert-manager/cert-manager/releases/download/v1.21.0/cert-manager.yaml

kubectl -n cert-manager rollout status deployment/cert-manager-webhook

```

Und [CloudNativePG](https://cloudnative-pg.io/?ref=prod-0-dol-blog-zrh1.dol.ch), den Postgres-Operator. Das `--server-side` ist hier nicht optional, die CRDs sind zu gross für die klassische Annotation:

```bash
kubectl apply --server-side -f \
  https://raw.githubusercontent.com/cloudnative-pg/cloudnative-pg/release-1.30/releases/cnpg-1.30.0.yaml

kubectl -n cnpg-system rollout status deployment/cnpg-controller-manager

```

## Die Datenbank

### Was am Deployment falsch war

Hier ist der Teil, den ich 2025 falsch gemacht habe. Damals standen da ein `Deployment` mit `replicas: 1`, ein PVC und ein `pg_dump`\-CronJob um vier Uhr morgens. Das läuft monatelang gut und hat drei Probleme, die alle erst auffallen, wenn es gerade nicht passt.

Ein `Deployment` rollt standardmässig mit `RollingUpdate` aus, startet also den neuen Pod, bevor der alte weg ist. Auf einem ReadWriteOnce-Volume bleibt der Rollout dann hängen, bis jemand eingreift. Kubernetes schickt zum Beenden ausserdem `SIGTERM`, was Postgres als "smart shutdown" liest: warten, bis alle Clients von selbst gehen. Sidekiq geht nicht, also kommt nach 30 Sekunden `SIGKILL` und beim nächsten Start eine Crash-Recovery. Und ein nächtlicher Dump heisst, dass ein Fehler um 14 Uhr dich einen halben Tag Föderation kostet.

### Der CNPG-Cluster

Der Operator nimmt einem das alles ab. Zuerst die Zugangsdaten für den Backup-Speicher:

```bash
kubectl create secret generic mastodon-backup-creds \
  --namespace mastodon \
  --from-literal=ACCESS_KEY_ID=dein_key \
  --from-literal=ACCESS_SECRET_KEY=dein_secret \
  --dry-run=client -o yaml > backup-creds.yaml

kubeseal --controller-namespace kube-system \
  --controller-name sealed-secrets-controller \
  --format yaml < backup-creds.yaml > sealed-backup-creds.yaml

kubectl apply -f sealed-backup-creds.yaml && rm backup-creds.yaml

```

Dann der Cluster:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: mastodon-db
  namespace: mastodon
spec:
  instances: 2
  imageName: ghcr.io/cloudnative-pg/postgresql:18.4

  # Auf drei kleinen Nodes ist "preferred" der Unterschied zwischen
  # "läuft" und "Pending, weil die Regel nicht erfüllbar ist"
  affinity:
    enablePodAntiAffinity: true
    topologyKey: kubernetes.io/hostname
    podAntiAffinityType: preferred

  storage:
    size: 20Gi
  walStorage:
    size: 10Gi

  postgresql:
    parameters:
      shared_buffers: "256MB"
      effective_cache_size: "768MB"
      work_mem: "8MB"
      maintenance_work_mem: "128MB"

  bootstrap:
    initdb:
      database: mastodon_production
      owner: mastodon

  backup:
    barmanObjectStore:
      destinationPath: s3://dein-bucket/mastodon
      endpointURL: https://dein-s3-endpunkt
      s3Credentials:
        accessKeyId:
          name: mastodon-backup-creds
          key: ACCESS_KEY_ID
        secretAccessKey:
          name: mastodon-backup-creds
          key: ACCESS_SECRET_KEY
      wal:
        compression: gzip
      data:
        compression: gzip
    retentionPolicy: "30d"

  resources:
    requests:
      cpu: "500m"
      memory: "1Gi"
    limits:
      memory: "1Gi"

```

Vier Punkte daran muss ich erklären.

`walStorage` als eigenes Volume trennt WAL und Daten, und der Unterschied zeigt sich, wenn eines von beiden vollläuft. Volles Datenvolume: Schreibzugriffe schlagen fehl, aber Postgres läuft weiter, das WAL-Archiv bleibt intakt, und du vergrösserst das Volume in Ruhe. Volles WAL-Volume, klassischerweise weil das Archiving in den Objektspeicher klemmt: Postgres stoppt hart mit einem PANIC, die Daten daneben bleiben aber unberührt. Auf einem gemeinsamen Volume enden beide Fälle in der harten Variante.

Der `backup`\-Block macht kontinuierliches WAL-Archiving: Statt einem Stand pro Nacht lässt sich jeder Zeitpunkt seit dem letzten Basis-Backup wiederherstellen. Eine Anmerkung zur Version, weil sich das gerade bewegt: Diese eingebaute `barmanObjectStore`\-Form ist seit CNPG 1.26 abgekündigt, und die [Release Notes von 1.30](https://cloudnative-pg.io/releases/cloudnative-pg-1-30.0-released/?ref=prod-0-dol-blog-zrh1.dol.ch) sagen klar, dass 1.31 sie entfernt. Auf 1.30 funktioniert sie noch und ist der Standard. Wer neuer installiert, nimmt besser gleich das [Barman-Cloud-Plugin](https://cloudnative-pg.io/plugin-barman-cloud/?ref=prod-0-dol-blog-zrh1.dol.ch), statt zweimal zu migrieren. Und für dieses Setup heisst das: Plane den Umzug aufs Plugin ein, bevor du den Operator auf 1.31 hebst.

`instances: 2` musst du nicht übernehmen. Auf drei Nodes mit je 4 GB kostet die zweite Instanz Speicher, den Sidekiq gern hätte, und sie macht das Scheduling enger: Fällt eine Node aus oder ist in Wartung, konkurrieren beide Postgres-Pods mit allem anderen um die zwei verbleibenden. Mit `instances: 1` bekommst du weiterhin Backups, Point-in-Time-Recovery, kontrollierte Updates und deklarative Konfiguration, nur eben keine automatische Übernahme bei einem Node-Ausfall. Für eine persönliche Instanz ist das ein legitimer Tausch, und ich halte es für keine Schande, das so zu fahren.

Der vierte Punkt fällt nur auf, wenn man ihn sucht: Hier steht nirgends ein `CREATE EXTENSION`. Mastodon 4.6 braucht nichts ausser `plpgsql`, und das bringt jede Postgres-Datenbank von Haus aus mit. Glaub das nicht mir, sondern den `enable_extension`\-Zeilen in der [db/schema.rb](https://github.com/mastodon/mastodon/blob/v4.6.4/db/schema.rb?ref=prod-0-dol-blog-zrh1.dol.ch) deiner Version, dort schaue ich vor dem Aufsetzen nach. Sollte dort eines Tages eine Extension auftauchen, ist `postInitApplicationSQL` unter `initdb` der richtige Ort dafür, und als trusted markierte Extensions darf seit Postgres 13 auch der Datenbank-Eigentümer während der Migration anlegen, ganz ohne Superuser.

### Automatisches Backup

Dazu das geplante Basis-Backup:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: ScheduledBackup
metadata:
  name: mastodon-db-daily
  namespace: mastodon
spec:
  # Sechs Felder, nicht fünf. Das erste sind Sekunden.
  schedule: "0 0 3 * * *"
  backupOwnerReference: self
  cluster:
    name: mastodon-db

```

Der Cron-Ausdruck hat mich beim ersten Versuch erwischt. CNPG benutzt eine Go-Bibliothek mit Sekundenfeld, ein aus einem normalen `CronJob` kopierter Fünf-Felder-Ausdruck bedeutet hier etwas anderes als gedacht.

### Services und Zugangsdaten

Der Operator legt jetzt drei Services an: `mastodon-db-rw` zeigt immer auf die aktuelle Primary, `mastodon-db-ro` auf die Replicas, `mastodon-db-r` auf alle. Genau das kann ein handgeschriebener Service mit festem Selector nicht, weil er nach einem Failover auf den falschen Pod zeigen würde.

Das Passwort für den Applikationsbenutzer erzeugt der Operator selbst und legt es im Secret `mastodon-db-app` ab. Kopiert wird es nirgendwohin: Die Deployments weiter unten lesen es direkt von dort, per `valueFrom`. So gibt es genau eine Quelle, und wenn der Operator das Passwort eines Tages rotiert, läuft nichts stillschweigend mit einer veralteten Kopie weiter. Falls du dich trotzdem mal von Hand mit `psql` verbinden willst:

```bash
kubectl -n mastodon get secret mastodon-db-app \
  -o jsonpath='{.data.password}' | base64 -d

```

## Valkey

Valkey hält bei Mastodon mehr als den Cache: Auch die Sidekiq-Warteschlangen liegen dort. Wer die verliert, verliert offene Jobs, also ausgehende Zustellungen, Push-Nachrichten und Medienverarbeitung. Deshalb ein `StatefulSet` mit eigenem Volume und nicht, wie bei mir 2025, ein `Deployment` mit angehängtem PVC.

```yaml
apiVersion: v1
kind: Service
metadata:
  name: valkey
  namespace: mastodon
spec:
  clusterIP: None
  selector:
    app: valkey
  ports:
    - port: 6379
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
  name: valkey
  namespace: mastodon
spec:
  serviceName: valkey
  replicas: 1
  selector:
    matchLabels:
      app: valkey
  template:
    metadata:
      labels:
        app: valkey
    spec:
      terminationGracePeriodSeconds: 60
      containers:
        - name: valkey
          image: valkey/valkey:9.1-alpine
          args: ["--appendonly", "yes", "--appendfsync", "everysec"]
          ports:
            - containerPort: 6379
          volumeMounts:
            - name: data
              mountPath: /data
          readinessProbe:
            exec:
              command: ["valkey-cli", "ping"]
            initialDelaySeconds: 5
            periodSeconds: 10
          resources:
            requests:
              cpu: "100m"
              memory: "256Mi"
            limits:
              memory: "512Mi"
  volumeClaimTemplates:
    - metadata:
        name: data
      spec:
        accessModes: ["ReadWriteOnce"]
        resources:
          requests:
            storage: 5Gi

```

Die Grace Period steht auf 60 Sekunden, weil Valkey beim Beenden seine Daten wegschreiben soll und die Standardvorgabe von 30 Sekunden dafür knapp ist.

## Secrets

Diese Werte werden einmal erzeugt und danach nie geändert. `SECRET_KEY_BASE` verschlüsselt Sessions, `OTP_SECRET` hängt an der Zwei-Faktor-Anmeldung, die VAPID-Schlüssel an den Push-Benachrichtigungen. Wer sie später austauscht, wirft alle Anmeldungen, alle 2FA-Einrichtungen und alle Push-Abos weg. Für `LOCAL_DOMAIN` gilt das noch strenger: Die Domain steckt in der Identität jedes Accounts, unter ihr kennen dich alle anderen Server. Änderst du sie später, ist die Föderation für die alte Identität unwiederbringlich weg, das lässt sich durch nichts reparieren.

```bash
# Active-Record-Schlüssel
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 \
  bin/rails db:encryption:init

# SECRET_KEY_BASE und OTP_SECRET, zweimal ausführen
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 bundle exec rake secret

# VAPID-Schlüsselpaar
docker run --rm ghcr.io/mastodon/mastodon:v4.6.4 \
  bundle exec rake mastodon:webpush:generate_vapid_key

```

Daraus wird das Secret. `DB_HOST` zeigt auf den `-rw`\-Service des Operators, und ein `DB_PASS` fehlt absichtlich: Das kommt gleich per `valueFrom` direkt aus `mastodon-db-app`:

```yaml
apiVersion: v1
kind: Secret
metadata:
  name: mastodon-env
  namespace: mastodon
type: Opaque
stringData:
  LOCAL_DOMAIN: "deine-domain.ch"
  SINGLE_USER_MODE: "true"
  RAILS_ENV: "production"

  SECRET_KEY_BASE: "..."
  OTP_SECRET: "..."
  VAPID_PRIVATE_KEY: "..."
  VAPID_PUBLIC_KEY: "..."
  ACTIVE_RECORD_ENCRYPTION_DETERMINISTIC_KEY: "..."
  ACTIVE_RECORD_ENCRYPTION_KEY_DERIVATION_SALT: "..."
  ACTIVE_RECORD_ENCRYPTION_PRIMARY_KEY: "..."

  DB_HOST: "mastodon-db-rw"
  DB_NAME: "mastodon_production"
  DB_USER: "mastodon"

  REDIS_HOST: "valkey"
  REDIS_PORT: "6379"
  CACHE_REDIS_URL: "redis://valkey:6379/1"

  SMTP_SERVER: "smtp.example.ch"
  SMTP_PORT: "587"
  SMTP_LOGIN: "mail@deine-domain.ch"
  SMTP_PASSWORD: "..."
  SMTP_FROM_ADDRESS: "mail@deine-domain.ch"

  S3_ENABLED: "true"
  S3_BUCKET: "dein-bucket"
  S3_REGION: "..."
  S3_ENDPOINT: "https://dein-s3-endpunkt"
  S3_FORCE_PATH_STYLE: "true"
  S3_ALIAS_HOST: "cdn.deine-domain.ch"
  AWS_ACCESS_KEY_ID: "..."
  AWS_SECRET_ACCESS_KEY: "..."

```

`CACHE_REDIS_URL` legt den Rails-Cache in Datenbank 1\. So kannst du im Notfall den Cache leeren, ohne die Sidekiq-Warteschlangen in Datenbank 0 anzufassen. Eine Grenze dieser Doppelnutzung solltest du kennen: Die Eviction-Policy gilt pro Instanz, nicht pro Datenbank. Die Warteschlangen vertragen nur `noeviction`, den Standard, also darf auch der Cache hier nie per `allkeys-lru` gedeckelt werden. Solange der Speicher reicht, spielt das keine Rolle. Wächst die Instanz, zieht der Cache in ein eigenes kleines Valkey um.

Verschlüsseln und die Klartextdatei löschen:

```bash
kubeseal --controller-namespace kube-system \
  --controller-name sealed-secrets-controller \
  --format yaml < mastodon-env.yaml > sealed-mastodon-env.yaml

kubectl apply -f sealed-mastodon-env.yaml
rm mastodon-env.yaml

```

## Migrationen

Vor dem ersten Start muss das Schema angelegt werden. Als Job, nicht als Init-Container, damit es nicht bei jedem Pod-Neustart erneut läuft:

```yaml
apiVersion: batch/v1
kind: Job
metadata:
  name: mastodon-db-migrate
  namespace: mastodon
spec:
  backoffLimit: 3
  template:
    spec:
      restartPolicy: OnFailure
      containers:
        - name: migrate
          image: ghcr.io/mastodon/mastodon:v4.6.4
          command: ["bundle", "exec", "rails", "db:migrate"]
          envFrom:
            - secretRef:
                name: mastodon-env
          env:
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mastodon-db-app
                  key: password

```

Hier taucht zum ersten Mal das Muster für das Datenbankpasswort auf: `envFrom` zieht alles aus `mastodon-env`, der einzelne `env`\-Eintrag holt `DB_PASS` direkt aus dem Operator-Secret. Derselbe Block steht ab jetzt in jedem Manifest, das mit der Datenbank redet. Falls in deinem `mastodon-env` noch ein altes `DB_PASS` liegt: Einzelne `env`\-Einträge schlagen `envFrom`, der direkte Verweis gewinnt.

Beim ersten Lauf spielt der Job die gesamte Migrationshistorie von Mastodon ab, ein paar hundert Migrationen, entsprechend dauert er ein paar Minuten.

Denselben Job brauchst du nach jedem Upgrade wieder, und weil Jobs unveränderlich sind, löschst du vorher den alten: `kubectl -n mastodon delete job mastodon-db-migrate`, dann neu anwenden. Und ein Blick in die Release Notes lohnt sich. Grössere Upgrades teilen ihre Migrationen: Der Teil vor dem Rollout läuft mit `SKIP_POST_DEPLOYMENT_MIGRATIONS=true`, der Rest danach.

## Web, Streaming und Sidekiq

### Web

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mastodon-web
  namespace: mastodon
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mastodon-web
  template:
    metadata:
      labels:
        app: mastodon-web
    spec:
      containers:
        - name: web
          image: ghcr.io/mastodon/mastodon:v4.6.4
          command: ["bundle", "exec", "puma", "-C", "config/puma.rb"]
          envFrom:
            - secretRef:
                name: mastodon-env
          env:
            - name: WEB_CONCURRENCY
              value: "1"
            - name: MAX_THREADS
              value: "3"
            - name: DB_POOL
              value: "5"
            - name: MALLOC_ARENA_MAX
              value: "2"
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mastodon-db-app
                  key: password
          ports:
            - containerPort: 3000
          readinessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 20
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 3000
            initialDelaySeconds: 60
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 5
          resources:
            requests:
              cpu: "300m"
              memory: "600Mi"
            limits:
              memory: "1.2Gi"

```

Das PVC für `/mastodon/public/system` aus meiner alten Anleitung ist hier weg. Mit `S3_ENABLED: true` liegen die Medien im Objektspeicher, das lokale Verzeichnis wird nicht gebraucht, und damit fällt auch der Init-Container weg, der früher die Berechtigungen zurechtgebogen hat.

Die Readiness Probe fehlte 2025 komplett. Ohne sie schickt das Gateway Anfragen an einen Pod, in dem Puma noch startet. Dazu kommt jetzt eine Liveness Probe, absichtlich träge eingestellt: Ein Puma, der wirklich hängt, hängt auch nach fünf Fehlversuchen im Abstand von 30 Sekunden noch, und ein bloss ausgelasteter bekommt so genug Luft, bevor ihn jemand grundlos neu startet.

Bei den Ressourcen zieht sich ein Muster durch alle Manifeste dieser Anleitung, den Postgres-Cluster eingeschlossen: Limits nur für Memory, keine für CPU. Memory ist nicht komprimierbar, ohne Limit reisst ein wachsender Prozess irgendwann die Node mit. CPU verteilt der Scheduler bei Knappheit dagegen selbst, anhand der Requests, und ein CPU-Limit würde Puma auch dann drosseln, wenn die Node gerade nichts anderes zu tun hat.

### Streaming

Streaming ist ein eigener Prozess mit eigenem Image:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mastodon-streaming
  namespace: mastodon
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mastodon-streaming
  template:
    metadata:
      labels:
        app: mastodon-streaming
    spec:
      containers:
        - name: streaming
          image: ghcr.io/mastodon/mastodon-streaming:v4.6.4
          envFrom:
            - secretRef:
                name: mastodon-env
          env:
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mastodon-db-app
                  key: password
          ports:
            - containerPort: 4000
          readinessProbe:
            httpGet:
              path: /api/v1/streaming/health
              port: 4000
            initialDelaySeconds: 10
          livenessProbe:
            httpGet:
              path: /api/v1/streaming/health
              port: 4000
            initialDelaySeconds: 30
            periodSeconds: 30
            timeoutSeconds: 10
            failureThreshold: 5
          resources:
            requests:
              cpu: "100m"
              memory: "200Mi"
            limits:
              memory: "400Mi"

```

### Sidekiq und Scheduler

Sidekiq verarbeitet die Föderation. Beim Beenden zählen hier zwei Werte: Kubernetes wartet nach dem `SIGTERM` bis zu `terminationGracePeriodSeconds`, aber Sidekiq hat einen eigenen Timeout und legt unfertige Jobs standardmässig schon nach 25 Sekunden zurück in die Warteschlange. Erst das `-t 110` unten schöpft die 120 Sekunden aus. Verloren geht ohne das Flag nichts, die Jobs laufen später erneut, sie fangen nur wieder von vorn an:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mastodon-sidekiq
  namespace: mastodon
spec:
  selector:
    matchLabels:
      app: mastodon-sidekiq
  template:
    metadata:
      labels:
        app: mastodon-sidekiq
    spec:
      terminationGracePeriodSeconds: 120
      containers:
        - name: sidekiq
          image: ghcr.io/mastodon/mastodon:v4.6.4
          command:
            - bundle
            - exec
            - sidekiq
            - -c
            - "5"
            - -t
            - "110"
            - -q
            - default,8
            - -q
            - push,6
            - -q
            - ingress,4
            - -q
            - mailers,2
            - -q
            - pull,1
          envFrom:
            - secretRef:
                name: mastodon-env
          env:
            - name: DB_POOL
              value: "5"
            - name: MALLOC_ARENA_MAX
              value: "2"
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mastodon-db-app
                  key: password
          resources:
            requests:
              cpu: "500m"
              memory: "300Mi"
            limits:
              memory: "600Mi"

```

In der Liste fehlt eine Queue, und zwar absichtlich. [Die Mastodon-Doku](https://docs.joinmastodon.org/admin/scaling/?ref=prod-0-dol-blog-zrh1.dol.ch) ist beim `scheduler` deutlich: nie in mehr als einem Prozess gleichzeitig. Das Deployment oben bekommt gleich einen Autoscaler, also läuft der Scheduler in einem eigenen Deployment mit genau einer Replica und ohne Autoscaling:

```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: mastodon-sidekiq-scheduler
  namespace: mastodon
spec:
  replicas: 1
  selector:
    matchLabels:
      app: mastodon-sidekiq-scheduler
  template:
    metadata:
      labels:
        app: mastodon-sidekiq-scheduler
    spec:
      containers:
        - name: sidekiq
          image: ghcr.io/mastodon/mastodon:v4.6.4
          command: ["bundle", "exec", "sidekiq", "-c", "1", "-q", "scheduler"]
          envFrom:
            - secretRef:
                name: mastodon-env
          env:
            - name: DB_POOL
              value: "2"
            - name: MALLOC_ARENA_MAX
              value: "2"
            - name: DB_PASS
              valueFrom:
                secretKeyRef:
                  name: mastodon-db-app
                  key: password
          resources:
            requests:
              cpu: "50m"
              memory: "300Mi"
            limits:
              memory: "512Mi"

```

Der Scheduler stösst die periodischen Arbeiten an: geplante Beiträge veröffentlichen, Trends berechnen, alte Daten aufräumen. Läuft er doppelt, passiert das alles doppelt, inklusive deiner geplanten Beiträge, die zweimal rausgehen.

### Autoscaling und Verbindungen

Dazu der Autoscaler für den Haupt-Worker, damit unter Last mehrere kleine Pods laufen statt eines grossen:

```yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: mastodon-sidekiq
  namespace: mastodon
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: mastodon-sidekiq
  minReplicas: 1
  maxReplicas: 3
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 80

```

Dem Sidekiq-Deployment oben fehlt dafür das `replicas`\-Feld. Sobald der HPA die Zahl verwaltet, würde jedes erneute `kubectl apply` und jeder ArgoCD-Sync den Wert aus der Datei zurückschreiben und die Skalierung wieder einstampfen. Ohne das Feld startet das Deployment mit einer Replica, danach hat der HPA das letzte Wort.

Skalieren nach CPU hat einen Haken: Bei der Föderation wartet Sidekiq die meiste Zeit auf fremde Server, ist also oft nicht CPU-gebunden. Die Warteschlangenlänge wäre das bessere Signal, kostet aber einen Adapter für benutzerdefinierte Metriken. Für eine kleine Instanz reicht CPU, man sollte nur wissen, dass es ein Näherungswert ist.

Noch etwas zu `DB_POOL`: Der Wert gilt pro Prozess. Ein Web-Pod mit fünf, bis zu drei Sidekiq-Pods mit je fünf, der Scheduler mit seinen zwei, dazu Streaming mit seinem Standard-Pool von zehn und gelegentlich ein Migrations-Job: zusammen um die vierzig Verbindungen, die Postgres-Vorgabe von 100 reicht dafür mit Luft. Falls du irgendwann deutlich mehr Prozesse fährst, dreh nicht einfach `max_connections` hoch, jede Verbindung kostet Speicher im ohnehin knappen Datenbank-Container, sondern stell [PgBouncer](https://www.pgbouncer.org/?ref=prod-0-dol-blog-zrh1.dol.ch) davor. Dabei kommt eine Zeile dazu, die man leicht übersieht: Im Transaction-Pooling-Modus muss `PREPARED_STATEMENTS` auf `false`, sonst kollidieren Rails' vorbereitete Anweisungen mit geteilten Verbindungen.

## Services und Gateway

### Warum Gateway API

```yaml
apiVersion: v1
kind: Service
metadata:
  name: mastodon-web
  namespace: mastodon
spec:
  selector:
    app: mastodon-web
  ports:
    - port: 80
      targetPort: 3000
---
apiVersion: v1
kind: Service
metadata:
  name: mastodon-streaming
  namespace: mastodon
spec:
  selector:
    app: mastodon-streaming
  ports:
    - port: 4000
      targetPort: 4000

```

2025 stand hier ein nginx-`Ingress`, und ich hätte ihn gern behalten. Das Kubernetes-Projekt hat [das Ende von ingress-nginx](https://www.kubernetes.dev/blog/2025/11/12/ingress-nginx-retirement/?ref=prod-0-dol-blog-zrh1.dol.ch) aber im November 2025 angekündigt und die Wartung im März 2026 eingestellt. Bestehende Installationen laufen weiter, nur kommt kein Patch mehr, auch nicht für das nächste CVE. Die Komponente, die jedes Byte aus dem offenen Internet parst, will ich so nicht weiterbetreiben.

Die Nachfolgefrage hat eine offizielle Antwort: [Gateway API](https://gateway-api.sigs.k8s.io/?ref=prod-0-dol-blog-zrh1.dol.ch) statt Ingress. Als Implementierung habe ich [Envoy Gateway](https://gateway.envoyproxy.io/?ref=prod-0-dol-blog-zrh1.dol.ch) genommen. CNCF-Projekt, Envoy darunter, und es installiert sich wie alles andere in dieser Anleitung, ein Manifest, kein Helm:

```bash
kubectl apply --server-side -f \
  https://github.com/envoyproxy/gateway/releases/download/v1.8.3/install.yaml

kubectl -n envoy-gateway-system rollout status deployment/envoy-gateway

```

Das `--server-side` aus demselben Grund wie bei CNPG, und das Manifest bringt die Gateway-API-CRDs (v1.5.1) gleich mit, die installierst du nicht separat.

### cert-manager anpassen

Ein Wort zur Kompatibilität, weil oben Kubernetes 1.36 steht: Envoy Gateway v1.8 ist offiziell gegen 1.32 bis 1.35 getestet, 1.36 stand beim Schreiben noch nicht in der [Matrix](https://gateway.envoyproxy.io/news/releases/matrix/?ref=prod-0-dol-blog-zrh1.dol.ch). Es hängt an keiner API, die sich in 1.36 geändert hat, und das nächste Release dürfte die Lücke schliessen. Wer strikt innerhalb der getesteten Matrix bleiben will, bleibt bei Kubernetes 1.35, das [bis Februar 2027 unterstützt](https://kubernetes.io/releases/?ref=prod-0-dol-blog-zrh1.dol.ch) wird und sich in dieser Anleitung exakt gleich verhält.

cert-manager kann HTTP-01-Challenges über ein Gateway beantworten, sucht aber standardmässig nicht nach Gateways. Der Schalter heisst `--enable-gateway-api`:

```bash
kubectl -n cert-manager patch deployment cert-manager --type=json \
  -p='[{"op":"add","path":"/spec/template/spec/containers/0/args/-","value":"--enable-gateway-api"}]'

```

Der Patch startet den Controller neu, was er ohnehin braucht, um die neuen CRDs zu sehen. Weil cert-manager oben aus dem statischen Manifest kommt, ist das Flag beim nächsten Re-Apply wieder weg, es gehört also in deine Kopie im Git.

Der ClusterIssuer ändert sich auch. Der alte hat HTTP-01 über einen Ingress gelöst, dieser baut pro Challenge eine temporäre HTTPRoute:

```yaml
apiVersion: cert-manager.io/v1
kind: ClusterIssuer
metadata:
  name: letsencrypt-prod
spec:
  acme:
    server: https://acme-v02.api.letsencrypt.org/directory
    email: mail@deine-domain.ch
    privateKeySecretRef:
      name: letsencrypt-prod-account
    solvers:
      - http01:
          gatewayHTTPRoute:
            parentRefs:
              - name: mastodon
                namespace: mastodon
                kind: Gateway

```

### Das Gateway und die Routen

Dann das Gateway selbst:

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: GatewayClass
metadata:
  name: envoy
spec:
  controllerName: gateway.envoyproxy.io/gatewayclass-controller
---
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
  name: mastodon
  namespace: mastodon
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
spec:
  gatewayClassName: envoy
  listeners:
    - name: http
      protocol: HTTP
      port: 80
    - name: https
      protocol: HTTPS
      port: 443
      hostname: deine-domain.ch
      tls:
        mode: Terminate
        certificateRefs:
          - name: deine-domain-tls

```

Die Annotation macht, was sie vorher auf dem Ingress gemacht hat: cert-manager sieht das Gateway, stellt das Zertifikat für den Hostname des Listeners aus und schreibt es in das Secret, auf das der Listener zeigt.

Daran hängen zwei Routen. Die erste leitet nur um, vom http-Listener auf https:

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mastodon-redirect
  namespace: mastodon
spec:
  parentRefs:
    - name: mastodon
      sectionName: http
  rules:
    - filters:
        - type: RequestRedirect
          requestRedirect:
            scheme: https
            statusCode: 301

```

Die zweite macht das eigentliche Routing:

```yaml
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: mastodon
  namespace: mastodon
spec:
  parentRefs:
    - name: mastodon
      sectionName: https
  hostnames:
    - deine-domain.ch
  rules:
    - matches:
        - path:
            type: PathPrefix
            value: /api/v1/streaming
      backendRefs:
        - name: mastodon-streaming
          port: 4000
    - matches:
        - path:
            type: PathPrefix
            value: /
      backendRefs:
        - name: mastodon-web
          port: 80

```

Ein paar Details, ausnahmsweise alle angenehm. Die Reihenfolge der Regeln sieht wichtig aus und ist es nicht: Die Gateway API legt fest, dass der längste passende Pfad gewinnt, egal wie sortiert. Bei nginx stimmte das auch, aber nur als Implementierungsdetail. Hier ist es Vertrag. Falls du dich fragst, ob die Redirect-Route die Challenges von Let's Encrypt schluckt: tut sie nicht. Die Challenge-Route von cert-manager matcht einen längeren Pfad, und länger gewinnt, ebenfalls per Spec. `proxy-body-size` hat hier absichtlich keinen Nachfolger: Envoy streamt Request-Bodies, statt sie zu puffern, es gibt kein Standardlimit zum Hochdrehen und keinen 413 mitten im Video-Upload. Und Websockets fürs Streaming funktionieren ohne jede Konfiguration.

### LoadBalancer und DNS

Envoy Gateway legt für das Gateway einen eigenen LoadBalancer-Service in `envoy-gateway-system` an. Dafür muss dein Cluster Services vom Typ LoadBalancer eine externe IP geben können: Bei Managed-Anbietern übernimmt das der Load Balancer aus meinem Setup ganz oben, auf Bare Metal braucht es etwas wie [MetalLB](https://metallb.io/?ref=prod-0-dol-blog-zrh1.dol.ch), sonst bleibt die Adresse einfach leer. Dein DNS-Eintrag zeigt ab jetzt dorthin:

```bash
kubectl -n mastodon get gateway mastodon

```

Die Spalte ADDRESS gehört ins DNS, PROGRAMMED sollte True sagen. Wenn du vom Ingress-Setup kommst: Gateway und Routen daneben aufbauen, DNS umstellen, und ingress-nginx erst entfernen, wenn in seinen Logs kein Verkehr mehr auftaucht.

## Der erste Account

Mit `SINGLE_USER_MODE` bleibt die Registrierung zu, und solange kein Account existiert, hat die Startseite nichts, worauf sie zeigen kann. Den Owner-Account legst du deshalb direkt im Web-Pod an:

```bash
kubectl -n mastodon exec deploy/mastodon-web -- \
  bin/tootctl accounts create dima \
  --email mail@deine-domain.ch --confirmed --role Owner

```

Der Befehl gibt ein temporäres Passwort aus. Damit einloggen, Passwort ändern, 2FA einrichten, fertig.

## Medien aufräumen

Ohne Aufräumen wächst der Objektspeicher unbegrenzt, weil jede geföderierte Datei zwischengespeichert wird.

```yaml
apiVersion: batch/v1
kind: CronJob
metadata:
  name: mastodon-media-cleanup
  namespace: mastodon
spec:
  schedule: "0 4 * * *"
  concurrencyPolicy: Forbid
  jobTemplate:
    spec:
      template:
        spec:
          restartPolicy: OnFailure
          containers:
            - name: cleanup
              image: ghcr.io/mastodon/mastodon:v4.6.4
              envFrom:
                - secretRef:
                    name: mastodon-env
              env:
                - name: DB_PASS
                  valueFrom:
                    secretKeyRef:
                      name: mastodon-db-app
                      key: password
              command:
                - /bin/bash
                - -c
                - |
                  bin/tootctl media remove --days=7
                  bin/tootctl media remove-orphans
                  bin/tootctl preview_cards remove --days=7
                  bin/tootctl statuses remove --days=14
                  bin/tootctl accounts prune

```

Vier Uhr ist Absicht, eine Stunde nach dem Basis-Backup, damit sich die beiden nicht überlappen. `concurrencyPolicy: Forbid` fehlte in meiner alten Fassung. Wenn ein Durchlauf länger als 24 Stunden braucht, startet sonst der nächste daneben, und zwei parallele Aufräumjobs auf derselben Datenbank sind keine gute Idee.

## Das Backup, das nicht mehr weh tut

2025 stand an dieser Stelle der längste und unangenehmste Abschnitt der ganzen Anleitung. Ich hatte ein eigenes Docker-Image gebaut: Ubuntu mit AWS CLI, rclone und dem Postgres-Client, dazu ein Bash-Skript mit Fehlerprüfung nach jedem Schritt und ein Registry-Secret, damit der Cluster das Image ziehen darf. Es funktionierte, und es waren sehr viele bewegliche Teile für eine Aufgabe, die eigentlich Standard ist.

Der `ScheduledBackup` von weiter oben ersetzt das vollständig. Die Datenbank landet über WAL-Archiving laufend im Objektspeicher, das Basis-Backup läuft nachts, die Aufbewahrung regelt `retentionPolicy`.

Die Medien brauchen kein eigenes Backup mehr, wenn sie ohnehin in S3 liegen. Was sie brauchen, ist Versionierung oder Replikation beim Anbieter, und das ist eine Einstellung am Bucket statt eines nächtlichen `rclone sync`.

Kontrollieren kannst du den Stand so:

```bash
kubectl -n mastodon get backups
kubectl -n mastodon get cluster mastodon-db \
  -o jsonpath='{.status.firstRecoverabilityPoint}'

```

Und der Teil, den ich vorher gar nicht hatte: Zurückspielen auf einen Zeitpunkt. Das erzeugt einen neuen Cluster, der alte läuft unangetastet weiter, du kannst vergleichen und dann umschalten:

```yaml
apiVersion: postgresql.cnpg.io/v1
kind: Cluster
metadata:
  name: mastodon-db-restore
  namespace: mastodon
spec:
  instances: 1
  imageName: ghcr.io/cloudnative-pg/postgresql:18.4
  storage:
    size: 20Gi
  walStorage:
    size: 10Gi
  bootstrap:
    recovery:
      source: mastodon-db
      recoveryTarget:
        targetTime: "2026-07-27 14:00:00+02"
  externalClusters:
    - name: mastodon-db
      barmanObjectStore:
        destinationPath: s3://dein-bucket/mastodon
        endpointURL: https://dein-s3-endpunkt
        s3Credentials:
          accessKeyId:
            name: mastodon-backup-creds
            key: ACCESS_KEY_ID
          secretAccessKey:
            name: mastodon-backup-creds
            key: ACCESS_SECRET_KEY

```

Der wiederhergestellte Cluster hat keinen `backup`\-Block, und das ist Absicht: Er ist eine Probe und hat im Objektspeicher nichts zu suchen. Machst du einen davon irgendwann zum Ernstfall, gib ihm einen eigenen `destinationPath` oder `serverName`, bevor du die Sicherung wieder einschaltest. Zeigt er auf den alten Pfad, archiviert er sein WAL direkt über die Historie des Clusters, den du gerade retten wolltest.

Bitte einmal machen

Spiel diesen Restore einmal durch, bevor du ihn brauchst. Ein Backup, dessen Wiederherstellung man nie geübt hat, ist eine Vermutung mit Dateigrösse.

## Wenn du von der alten Anleitung kommst

Falls bei dir noch Postgres als `Deployment` läuft: CNPG kann beim Anlegen direkt aus einer bestehenden Datenbank importieren, statt dass du mit `pg_dump` und `pg_restore` hantierst.

```yaml
spec:
  bootstrap:
    initdb:
      database: mastodon_production
      owner: mastodon
      import:
        type: microservice
        databases:
          - mastodon_production
        source:
          externalCluster: alte-instanz
  externalClusters:
    - name: alte-instanz
      connectionParameters:
        host: postgres
        user: mastodon
        dbname: mastodon_production
      password:
        name: mastodon-env
        key: DB_PASS

```

Eine Eigenheit muss man dabei kennen: Der Import läuft genau einmal, beim Anlegen des Clusters. Einen zweiten Durchgang gibt es nur, indem du den neuen Cluster löschst und noch einmal anlegst. Der Ablauf sieht deshalb so aus: Cluster mit `import` anlegen und Mastodon nicht anfassen, das ist die Generalprobe. Zeilenzahlen der grossen Tabellen gegen die alte Datenbank halten, danach den neuen Cluster wieder löschen. Für den echten Umzug Web, Streaming und Sidekiq auf null skalieren, ab hier ist Downtime, und den Cluster erneut mit `import` anlegen, diesmal ohne Lücke. `DB_HOST` im Secret auf `mastodon-db-rw` umbiegen, hochskalieren, Logs beobachten. Das alte `DB_PASS` im Secret kannst du bei der Gelegenheit löschen, die neuen Manifeste lesen das Passwort direkt aus `mastodon-db-app`.

Das alte Deployment und sein PVC lässt du danach eine Woche stehen, auch wenn es dich stört. Gelöscht wird der Rückweg erst, wenn du sicher bist, dass du ihn nicht brauchst.

## Was ich gegenüber 2025 geändert habe

| Damals                   | Jetzt                                | Warum                                                  |
| ------------------------ | ------------------------------------ | ------------------------------------------------------ |
| Postgres als Deployment  | CloudNativePG                        | Kein sauberer Rollout, kein Failover, kein PITR        |
| pg\_dump per CronJob     | WAL-Archiving                        | RPO von 24 Stunden auf Minuten                         |
| Eigenes Backup-Image     | ScheduledBackup                      | Ein Image, ein Skript, ein Registry-Secret weniger     |
| Redis als Deployment     | Valkey als StatefulSet               | Sidekiq-Warteschlangen überleben Neustarts             |
| Scheduler im Worker-Pool | Eigenes Deployment mit einer Replica | Periodische Jobs dürfen nicht doppelt laufen           |
| Keine Probes             | Readiness und Liveness               | Kein Verkehr auf startende, Neustart für hängende Pods |
| tootsuite/mastodon       | ghcr.io/mastodon/...                 | Offizielle Registry, konsistente Tags                  |
| PVC für Medien           | Nur S3                               | Ein Volume und ein Init-Container weniger              |
| nginx-Ingress            | Envoy Gateway + Gateway API          | ingress-nginx wurde im März 2026 eingestellt           |

Was ich nicht geändert habe: Sealed Secrets, den Verzicht auf Helm, kein Elasticsearch auf kleinen Nodes, den Autoscaler für Sidekiq und das nächtliche Aufräumen der Medien. Das war schon damals richtig.

Und eine Warnung, die zur Ehrlichkeit gehört: Zwei Postgres-Instanzen schützen dich vor einem toten Node, nicht vor einem `DELETE` ohne `WHERE`. Das repliziert sauber auf beide. Hochverfügbarkeit und Backup lösen zwei verschiedene Probleme, und nur eines davon lässt sich nachts um drei mit einem Kaffee reparieren.

Wenn du das nachbaust und irgendwo hängst, schreib mir. Beim letzten Mal kamen die besten Korrekturen von Leuten, die einfach ausprobiert haben, was ich aufgeschrieben hatte.