Zum Hauptinhalt springen
S-EDV news
← Alle Anleitungen
📘 Anleitung Datenbanken 14.08.2026 · 13 min Lesezeit Geprüft 12.11.2026

Payload CMS 3.93.0 mit Docker installieren: Fullstack Next.js Headless CMS selbst hosten

Payload CMS 3.93.0 ist ein TypeScript-basiertes Headless CMS für Next.js. Die Anleitung zeigt, wie Sie Ihr Payload-Projekt per Multi-Stage-Dockerfile und Docker Compose mit PostgreSQL bauen, Migrationen beim Start ausführen und sicher betreiben.

Werbung Mit * markierte Links sind Werbelinks: Bei einem Kauf erhalten wir eine Provision, der Preis bleibt gleich. Als Amazon-Partner verdiene ich an qualifizierten Verkäufen. Mehr dazu

Titelbild: Payload CMS mit Docker installieren, Inhalte, Admin, API

Payload CMS ist ein quelloffenes, TypeScript-basiertes Headless CMS, das als Teil einer Next.js-App läuft. Payload 3 bringt REST-API, GraphQL-Endpunkt und Admin-Panel in einem einzigen Node.js-Prozess mit; Collections, Felder und Hooks konfigurieren Sie im Quellcode. Für Docker bedeutet das: Es gibt kein fertiges Image, jede Instanz wird aus dem eigenen Projekt gebaut. Diese Anleitung zeigt Dockerfile, compose.yaml mit PostgreSQL und die erste Admin-Einrichtung für Payload 3.93.0 mit Node.js 22 und PostgreSQL 16. Sie umfasst eine Neuinstallation aus create-payload-app sowie die Updates von 3.90.0 auf 3.91.0, von 3.91.0 auf 3.92.0 und von 3.92.0 auf 3.93.0.

Voraussetzungen

  • Docker Engine 24+ und Docker Compose v2 (docker compose) auf einem Linux-Host oder einer VM, x86_64 oder ARM64. Installation: Docker-Grundlagen-Anleitung.
  • Eigenes Payload-Projekt, erstellt mit npx create-payload-app (Vorlage blank mit PostgreSQL) und in Git versioniert.
  • Node.js 22 ab 22.23.4 oder ab 24.22.1 (Vorgabe engines von Payload 3.93.0) lokal für Entwicklung und das Erzeugen von Migrationen.
  • pnpm 10 oder npm mit Lockfile (pnpm-lock.yaml bzw. package-lock.json) im Projekt. Bei pnpm legen Sie die Version einmal mit corepack use pnpm@10 fest (Schritt 1).
  • 2 CPU-Kerne und 4 GB RAM: Der Container braucht im Betrieb etwa 1 GB, der Build zusätzlich etwa 2 GB.
  • Reverse Proxy mit HTTPS für den Produktivbetrieb, z. B. Traefik als Docker-Reverse-Proxy.

Eckdaten auf einen Blick

ParameterWertHinweis
Image (Payload)Selbst gebaut (kein Hub-Image)Multi-Stage auf node:22-alpine
Image (Datenbank)postgres:16-alpineOffizielles Image, Tag festgelegt
Port127.0.0.1:3000:3000App + Admin-Panel (/admin) + API (/api)
Volume Medienpayload-media:/app/mediaPflicht, sonst gehen Uploads verloren
Volume Datenbankpayload-postgres-data:/var/lib/postgresql/dataPflicht für Datenpersistenz
Pflicht-EnvsPAYLOAD_SECRET, DATABASE_URI, POSTGRES_PASSWORDAlle in .env speichern, nie committen
LizenzMIT (Open Source)Selbst betreibbar ohne Abo

Schritt 1: Projektordner und Dateien vorbereiten

Legen Sie auf dem Server einen Arbeitsordner an und holen Sie Ihr Projekt per Git:

$ mkdir -p /opt/payload-cms
$ cd /opt/payload-cms
# Projekt per Git klonen (oder Dateien übertragen):
$ git clone https://git.example.com/firma/payload-projekt.git .

Das Projekt braucht für den Docker-Build drei Dateien:

  1. Dockerfile: Multi-Stage-Build (Schritt 2), ersetzt das Dockerfile der Vorlage
  2. .dockerignore: schließt node_modules, .next und .env aus
  3. next.config.ts: muss output: 'standalone' enthalten

Die Vorlage bringt außerdem eine eigene docker-compose.yml mit. Löschen Sie diese, sonst meldet Compose neben der compose.yaml aus Schritt 4 „Found multiple config files“:

$ rm docker-compose.yml

Ergänzen Sie in next.config.ts die Zeile output: 'standalone'; die Vorlage enthält sie nicht. Die Umschließung mit withPayload() bleibt bestehen:

// next.config.ts (Auszug)
import { withPayload } from '@payloadcms/next/withPayload'
import type { NextConfig } from 'next'

const nextConfig: NextConfig = {
  output: 'standalone',
  // weitere Konfiguration der Vorlage bleibt
}

export default withPayload(nextConfig, { devBundleServerPackages: false })

Legen Sie eine .dockerignore an, damit der Build-Kontext klein bleibt:

node_modules
.next
.env
.env.local
dist

Nutzen Sie pnpm, legen Sie die Version einmal im Projekt fest. Corepack lädt sonst pnpm 12, das pnpm.onlyBuiltDependencies aus der package.json nicht mehr liest; der Docker-Build bricht dann mit ERR_PNPM_IGNORED_BUILDS ab. Der Befehl schreibt packageManager in die package.json, Corepack nutzt im Build dieselbe Version:

corepack use pnpm@10

ls -la /opt/payload-cms zeigt package.json, next.config.ts, .dockerignore und src/ mit payload.config.ts, aber keine docker-compose.yml; grep standalone next.config.ts liefert einen Treffer. Bei pnpm zeigt grep packageManager package.json einen Eintrag pnpm@10….

Schritt 2: Multi-Stage Dockerfile erstellen

Ein Multi-Stage-Dockerfile hält das Image klein: Die Stage builder kompiliert die App, die Stage runner enthält nur den Standalone-Output. Die Vorlage orientiert sich am Dockerfile der offiziellen Payload-Templates und läuft als unprivilegierter Benutzer.

Migrationen: Der Standalone-Output enthält die Payload-CLI nicht; ein payload migrate im laufenden Container funktioniert deshalb nicht zuverlässig. Payload empfiehlt für langlebige Container die Option prodMigrations: Payload führt ausstehende Migrationen dann beim Start selbst aus. Neue Projekte haben noch kein Verzeichnis src/migrations/. Legen Sie vor dem ersten Build die erste Migration an und committen Sie sie. Der Befehl braucht eine erreichbare PostgreSQL-Datenbank, die über DATABASE_URI gesetzt ist (z. B. Ihre lokale Entwicklungsdatenbank):

$ pnpm payload migrate:create initial
# mit npm: npx payload migrate:create initial

Danach existiert src/migrations/index.ts.

# Dockerfile (angelehnt an die offiziellen Payload-Templates)
FROM node:22-alpine AS base

FROM base AS deps
RUN apk add --no-cache libc6-compat
WORKDIR /app
COPY package.json pnpm-lock.yaml* package-lock.json* .npmrc* ./
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm i --frozen-lockfile; \
    else npm ci; fi

FROM base AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Neue Projekte aus create-payload-app haben kein public/
RUN mkdir -p public
ENV NEXT_TELEMETRY_DISABLED=1
RUN if [ -f pnpm-lock.yaml ]; then corepack enable pnpm && pnpm run build; \
    else npm run build; fi

FROM base AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs && adduser --system --uid 1001 nextjs
COPY --from=builder /app/public ./public
RUN mkdir -p .next media && chown -R nextjs:nodejs .next media
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

Die Zeile mit .npmrc* ist wichtig: create-payload-app legt eine .npmrc mit legacy-peer-deps=true an und erzeugt die package-lock.json mit dieser Einstellung. Fehlt die Datei im Build, bricht npm ci mit „package.json and package-lock.json … are not in sync“ ab. RUN mkdir -p public sorgt dafür, dass COPY --from=builder /app/public auch ohne eigenes public/ funktioniert. Die Collection media der Vorlage speichert Uploads relativ zum Arbeitsverzeichnis, also unter /app/media; der Runner legt dieses Verzeichnis für den Benutzer nextjs an.

Die Vorlage liest die Datenbankadresse aus DATABASE_URL, diese Anleitung setzt DATABASE_URI. Ändern Sie deshalb in src/payload.config.ts den Ausdruck process.env.DATABASE_URL in process.env.DATABASE_URI und tragen Sie prodMigrations ein:

// src/payload.config.ts (Auszug)
import { postgresAdapter } from '@payloadcms/db-postgres'
import { migrations } from './migrations'

export default buildConfig({
  // ...
  db: postgresAdapter({
    pool: { connectionString: process.env.DATABASE_URI || '' },
    prodMigrations: migrations,
  }),
})

ls src/migrations/index.ts existiert, grep prodMigrations src/payload.config.ts liefert einen Treffer, und grep DATABASE_URL src/payload.config.ts liefert keinen mehr. Ob alle Stages fehlerfrei bauen, zeigt Schritt 4.

Schritt 3: .env-Datei mit Secrets anlegen

Sensible Werte gehören in .env, die nie ins Git-Repository gelangt. Docker Compose liest sie automatisch aus dem Projektordner. Erzeugen Sie zuerst ein zufälliges PAYLOAD_SECRET:

$ openssl rand -hex 32

Legen Sie dann .env an. Sie ersetzt die .env aus create-payload-app, die noch DATABASE_URL enthält:

# /opt/payload-cms/.env (nicht ins Git-Repository)
PAYLOAD_SECRET=HIER_AUSGABE_VON_OPENSSL_EINTRAGEN
POSTGRES_PASSWORD=HIER_ZUFAELLIGES_PASSWORT_EINTRAGEN
NEXT_PUBLIC_SERVER_URL=https://cms.example.com

DATABASE_URI setzt compose.yaml aus diesen Variablen zusammen. Das Datenbankpasswort sollte nur Buchstaben und Ziffern enthalten (z. B. aus openssl rand -hex 24), weil Sonderzeichen in der URI kodiert werden müssten.

chmod 600 .env, danach liefert grep -c PAYLOAD_SECRET .env den Wert 1, und grep -x .env .gitignore bestätigt den Ausschluss aus Git.

Schritt 4: compose.yaml anlegen und Stack starten

compose.yaml definiert den selbst gebauten Payload-Container und PostgreSQL mit Healthcheck. condition: service_healthy startet Payload erst, wenn PostgreSQL Verbindungen annimmt. Der Port ist an 127.0.0.1 gebunden: Bis Sie den ersten Admin angelegt haben, könnte sonst jeder im Netz das Admin-Konto registrieren.

# /opt/payload-cms/compose.yaml
services:
  payload:
    build:
      context: .
      dockerfile: Dockerfile
    container_name: payload
    restart: unless-stopped
    ports:
      - "127.0.0.1:3000:3000"
    volumes:
      - payload-media:/app/media
    environment:
      NODE_ENV: production
      DATABASE_URI: postgres://payload:${POSTGRES_PASSWORD}@payload-postgres:5432/payload
      PAYLOAD_SECRET: ${PAYLOAD_SECRET}
      NEXT_PUBLIC_SERVER_URL: ${NEXT_PUBLIC_SERVER_URL:-http://localhost:3000}
      NEXT_TELEMETRY_DISABLED: "1"
      HOSTNAME: "0.0.0.0"
      PORT: "3000"
    depends_on:
      payload-postgres:
        condition: service_healthy

  payload-postgres:
    image: postgres:16-alpine
    container_name: payload-postgres
    restart: unless-stopped
    volumes:
      - payload-postgres-data:/var/lib/postgresql/data
    environment:
      POSTGRES_DB: payload
      POSTGRES_USER: payload
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U payload"]
      interval: 10s
      timeout: 5s
      retries: 5

volumes:
  payload-media:
  payload-postgres-data:

Starten Sie den Stack. Der erste Build dauert je nach Hardware 3 bis 10 Minuten:

$ cd /opt/payload-cms
$ docker compose up -d --build

Verfolgen Sie Build und Start:

$ docker compose logs -f

Prüfen Sie den Status beider Container:

$ docker compose ps

Erwartet: beide Container Up, PostgreSQL zusätzlich healthy:

NAME               IMAGE                STATUS                   PORTS
payload            payload-cms-payload  Up 2 minutes             127.0.0.1:3000->3000/tcp
payload-postgres   postgres:16-alpine   Up 2 minutes (healthy)

Prüfen Sie die HTTP-Erreichbarkeit:

$ curl -I http://localhost:3000
# Erwartete Antwort: HTTP/1.1 200 OK  oder  HTTP/1.1 302 Found

Schritt 5: Admin-Benutzer anlegen und Admin-Panel prüfen

Beim ersten Aufruf von /admin zeigt Payload ein Formular für den ersten Benutzer. Von einem anderen Rechner erreichen Sie den Server vor der Proxy-Einrichtung per SSH-Tunnel:

# Auf dem eigenen Rechner: Tunnel zum Server aufbauen
$ ssh -L 3000:127.0.0.1:3000 benutzer@server
# Dann im Browser: http://localhost:3000/admin

Geben Sie E-Mail-Adresse und Passwort ein. Danach ist die Erstregistrierung geschlossen; weitere Benutzer legen Sie im Admin-Panel oder per API an. Unter Collections sehen Sie die Inhaltstypen aus payload.config.ts, unter Globals globale Einstellungen.

Die Logs zeigen keine Fehler zu Datenbank oder Umgebungsvariablen:

$ docker compose logs payload --tail=50

Die REST-API antwortet:

$ curl http://localhost:3000/api/users/me
# Erwartet: {"user":null,"message":"Account"}

Schritt 6: Reverse Proxy und HTTPS einrichten (Produktion)

Im Produktivbetrieb terminiert ein Reverse Proxy TLS und leitet an 127.0.0.1:3000 weiter; läuft der Proxy selbst als Container, nutzen Sie ein gemeinsames Docker-Netz statt der Port-Freigabe. Die Einrichtung mit Traefik beschreibt Traefik als Docker-Reverse-Proxy mit automatischem HTTPS; Caddy oder Nginx Proxy Manager funktionieren ebenso. Setzen Sie danach NEXT_PUBLIC_SERVER_URL in .env auf die HTTPS-Adresse und führen Sie docker compose up -d --build aus.

$ curl -I https://cms.example.com/admin
# Erwartete Antwort: HTTP/2 200  oder  HTTP/2 302

Laden Sie zusätzlich ein Bild hoch: Die Medien-URL im Admin-Panel beginnt mit https://cms.example.com/.

Schritt 7: Updates und Backups

Ein Update heißt: Payload-Pakete in package.json anheben, lokal testen, nötige Migrationen erzeugen, committen und auf dem Server neu bauen. Sichern Sie vorher die Datenbank. Bei pnpm muss die Version wie in Schritt 1 auf pnpm 10 festgelegt sein, sonst scheitert der Neubau mit ERR_PNPM_IGNORED_BUILDS.

# Lokal: Payload-Pakete gemeinsam anheben (Beispiel pnpm)
$ pnpm update payload "@payloadcms/*" --latest
$ pnpm payload migrate:create   # nur bei Schemaänderungen
$ git commit -am "Payload-Update" && git push
# Server: Backup, dann neu bauen
$ docker exec payload-postgres pg_dump -U payload payload > /opt/backups/payload-vor-update.sql
$ git pull
$ docker compose up -d --build

Update auf 3.92.0: Laut Release Notes ist die einzige Breaking Change die höhere Node-Mindestversion: Payload 3.92.0 verlangt Node.js 22.23.4 oder neuer bzw. 24.22.1 oder neuer. Lokal meldet pnpm bei einem älteren Node-Stand Unsupported engine. Bauen Sie das Image auf dem Server deshalb mit frisch gezogenem Basis-Image (docker compose build --pull, danach docker compose up -d), damit node:22-alpine nicht aus einem alten lokalen Cache kommt. Dazu kommen Fehlerbehebungen, unter anderem die Übergabe der Dokument-ID an Feld-Zugriffsregeln und die Prüfung jeder Sprache beim Schreiben lokalisierter Felder mit locale=all. Das Update von 3.91.0 auf 3.92.0 läuft mit genau diesen Schritten: pnpm update hebt alle Payload-Pakete auf 3.92.0, der Neubau mit dem Dockerfile aus Schritt 2 läuft durch, /admin antwortet mit HTTP 200, der vorher angelegte Benutzer meldet sich an, und Medien-Eintrag und Bilddatei sind noch abrufbar.

Update auf 3.93.0: Die Release Notes kennzeichnen 3.93.0 als Version mit wichtigen Sicherheitskorrekturen und empfehlen ein zügiges Update. Unter den Fehlerbehebungen sind unter anderem eine strengere Lesezugriffsprüfung für Blöcke in Abfragen, die Durchsetzung der pasteURL-Beschränkungen beim Hochladen über eine entfernte URL und das Escaping gespeicherter Werte in den HTML-Konvertern von @payloadcms/richtext-lexical. Breaking Changes nennen die Release Notes nicht, die Node-Mindestversion bleibt wie bei 3.92.0. Das Update von 3.92.0 auf 3.93.0 läuft mit den Schritten oben: pnpm update payload "@payloadcms/*" --latest hebt payload und alle @payloadcms-Pakete auf 3.93.0, der Neubau läuft durch, /admin antwortet mit HTTP 200, der vorher angelegte Benutzer meldet sich an, eine vor dem Update ausgestellte Sitzung bleibt gültig, und der zuvor angelegte Medien-Eintrag steht weiter in der Datenbank.

Datenbank-Backup mit pg_dump aus dem laufenden Container:

docker exec payload-postgres pg_dump -U payload payload \
  | gzip > /opt/backups/payload-$(date +%Y%m%d).sql.gz

Automatisierung und Cloud-Kopie beschreibt MySQL & PostgreSQL Backup automatisieren mit cron. Sichern Sie zusätzlich das Volume payload-media, z. B. per tar.

Nach dem Update:

$ docker compose ps
# Beide Container wieder Up + healthy
$ curl -I http://localhost:3000/admin
# HTTP 200 oder 302
$ docker compose logs payload --tail=20
# Keine Fehler; ausstehende Migrationen wurden beim Start ausgeführt

Troubleshooting / Typische Fehler

„connect ECONNREFUSED 127.0.0.1:5432“ beim Build

Eine Seite fragt beim Build Daten ab, oder Migrationen laufen im Build. Nutzen Sie prodMigrations (Schritt 2) und rendern Sie datenabhängige Seiten dynamisch.

Container läuft, /admin nicht erreichbar

HOSTNAME=0.0.0.0 fehlt, der Next.js-Server lauscht dann nur im Container auf localhost.

Uploads verschwinden nach Neustart

Kein Volume für /app/media, oder es hängt noch am alten Pfad /app/public/media. Prüfen: docker inspect payload --format '{{json .Mounts}}'.

server.js fehlt

output: 'standalone' fehlt in next.config.ts. Ergänzen und neu bauen.

„missing secret key“

PAYLOAD_SECRET ist leer oder fehlt in .env. Mit openssl rand -hex 32 erzeugen.

Datenbankverbindung scheitert beim Start

Healthcheck oder condition: service_healthy fehlt, oder das Passwort enthält Sonderzeichen, die die URI brechen.

Falsche Medien-URLs

NEXT_PUBLIC_SERVER_URL fehlt oder zeigt auf die falsche Domain. Korrigieren und neu bauen.

Build sehr langsam

.dockerignore fehlt, node_modules landet im Build-Kontext.

EACCES beim Upload

Das Volume gehört root, der Container läuft als nextjs (UID 1001). Die Meldung lautet EACCES: permission denied, mkdir 'media'. Das Dockerfile legt /app/media deshalb vorab mit passendem Besitzer an; das Volume muss auf /app/media zeigen.

„package.json and package-lock.json … are not in sync“ bei npm ci

Die .npmrc fehlt im Build. Die Stage deps muss sie mitkopieren (.npmrc* in der COPY-Zeile).

"/app/public": not found beim Build

Das Projekt hat kein public/. Die Zeile RUN mkdir -p public in der Stage builder fehlt.

ERR_PNPM_IGNORED_BUILDS (esbuild, unrs-resolver)

Corepack nutzt pnpm 12. Im Projekt corepack use pnpm@10 ausführen, committen und neu bauen.

„Found multiple config files“

Die docker-compose.yml der Vorlage liegt noch im Projekt. Löschen Sie sie.

Häufige Fragen

Kann ich Payload CMS ohne eigenen Quellcode als fertiges Docker-Image betreiben?

Nein. Es gibt kein offizielles vorgefertigtes Image. Collections, Felder und Hooks stehen im Quellcode und werden beim Build ins Image übernommen.

Welche Datenbank soll ich in Production verwenden?

PostgreSQL und MongoDB werden vollständig unterstützt; diese Anleitung nutzt PostgreSQL. SQLite eignet sich für Entwicklung, nicht für Produktivbetrieb mit mehreren Instanzen.

Wie lege ich den ersten Admin-Benutzer an?

Beim ersten Aufruf von /admin erscheint ein Registrierungsformular. Danach ist es gesperrt; weitere Benutzer entstehen im Admin-Panel oder per API.

Wie führe ich Datenbankmigrationen durch?

Migrationen erzeugen Sie lokal mit pnpm payload migrate:create; sie liegen in src/migrations/. Mit prodMigrations führt Payload ausstehende Migrationen beim Containerstart aus. Alternativ laufen sie in einer CI-Pipeline mit Datenbankzugriff vor dem Build (payload migrate && pnpm build).

Kann ich Payload CMS auf einem ARM64-Server betreiben?

Ja. node:22-alpine gibt es für amd64 und arm64. Native Module wie sharp installiert der Paketmanager im Build passend zur Zielplattform; bauen Sie deshalb auf der Zielarchitektur oder mit docker buildx --platform.

Wie aktualisiere ich Payload CMS auf eine neue Version?

Heben Sie die @payloadcms/*-Pakete und payload gemeinsam auf dieselbe Version an (Schritt 7, bei pnpm mit festgelegter Version 10), testen Sie lokal, committen Sie und führen Sie auf dem Server git pull und docker compose up -d --build aus.

Was passiert mit Mediendateien bei einem Cloud-Deployment ohne persistenten Speicher?

Auf Plattformen mit flüchtigem Dateisystem gehen Uploads verloren. Payload bietet Storage-Adapter für S3-kompatiblen Speicher, Azure Blob Storage und Google Cloud Storage. Das Volume payload-media genügt nur bei persistentem Speicher.

Fazit

Payload 3 eignet sich für Teams, die ihr CMS im Code versionieren und typsicher konfigurieren. Der Docker-Betrieb verlangt einen eigenen Build; dafür enthält das Image genau Ihre Collections und Konfiguration, und die Daten bleiben auf Ihrem Server. Dockerfile, prodMigrations und compose.yaml lassen sich für weitere Projekte wiederverwenden. Wer ein fertiges Image bevorzugt, findet in Directus mit Docker eine Alternative; für Datenbankumzüge hilft die PostgreSQL-Backup- und Migrations-Anleitung.

Weiterführende Anleitungen und Quellen

  1. Docker Compose: Multi-Container-Stacks aufbauen: Compose-Syntax und Stack-Konzepte vertieft erklärt
  2. Traefik als Docker-Reverse-Proxy mit automatischem HTTPS: Payload hinter TLS betreiben
  3. MySQL & PostgreSQL Backup automatisieren mit cron: Payload-Datenbank regelmäßig sichern
  4. Docker Compose absichern: Secrets, Healthchecks, Non-Root: Produktionshärtung für den Stack
  5. Directus mit Docker: Alternative Headless-CMS-Lösung mit fertigem Image

Offizielle Quellen: Payload CMS: Production Deployment | Payload CMS auf GitHub | Payload CMS: Migrations