Dmytro Oliinyk

Dmytro Oliinyk · 28. Juli 2026 · 24 Min. Lesezeit

Auf Mastodon teilen

Anleitung · Kubernetes

Mastodon 4.6 auf Kubernetes installieren

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 Lehren aus dem ersten.

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

KomponenteVersionAnmerkung
Mastodonv4.6.4verlangt Postgres 14+, Redis 7.0+
PostgreSQL18.4über CloudNativePG
CloudNativePG1.30.0der Operator
Valkey9.1.1Redis-Fork, protokollkompatibel
Sealed Secretsv0.38.4Controller und CLI
cert-managerv1.21.0für Let's Encrypt
Envoy Gatewayv1.8.3bringt Gateway API 1.5.1 mit

Valkey statt Redis ist eine bewusste Entscheidung. Mastodon dokumentiert Redis ab 7.0, und Valkey 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:

brew install kubeseal

Unter Linux:

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#

kubectl create namespace mastodon

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

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:

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, den Postgres-Operator. Das --server-side ist hier nicht optional, die CRDs sind zu gross für die klassische Annotation:

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:

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:

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 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, 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 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:

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:

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.

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.

# 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:

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:

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:

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#

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:

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:

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

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:

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 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#

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 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 statt Ingress. Als Implementierung habe ich Envoy Gateway genommen. CNCF-Projekt, Envoy darunter, und es installiert sich wie alles andere in dieser Anleitung, ein Manifest, kein Helm:

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

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:

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:

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:

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:

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, sonst bleibt die Adresse einfach leer. Dein DNS-Eintrag zeigt ab jetzt dorthin:

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:

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.

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:

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:

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.

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#

DamalsJetztWarum
Postgres als DeploymentCloudNativePGKein sauberer Rollout, kein Failover, kein PITR
pg_dump per CronJobWAL-ArchivingRPO von 24 Stunden auf Minuten
Eigenes Backup-ImageScheduledBackupEin Image, ein Skript, ein Registry-Secret weniger
Redis als DeploymentValkey als StatefulSetSidekiq-Warteschlangen überleben Neustarts
Scheduler im Worker-PoolEigenes Deployment mit einer ReplicaPeriodische Jobs dürfen nicht doppelt laufen
Keine ProbesReadiness und LivenessKein Verkehr auf startende, Neustart für hängende Pods
tootsuite/mastodonghcr.io/mastodon/...Offizielle Registry, konsistente Tags
PVC für MedienNur S3Ein Volume und ein Init-Container weniger
nginx-IngressEnvoy Gateway + Gateway APIingress-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.

Mastodon