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

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(Vorlageblankmit PostgreSQL) und in Git versioniert. - Node.js 22 ab 22.23.4 oder ab 24.22.1 (Vorgabe
enginesvon Payload 3.93.0) lokal für Entwicklung und das Erzeugen von Migrationen. - pnpm 10 oder npm mit Lockfile (
pnpm-lock.yamlbzw.package-lock.json) im Projekt. Bei pnpm legen Sie die Version einmal mitcorepack use pnpm@10fest (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
| Parameter | Wert | Hinweis |
|---|---|---|
| Image (Payload) | Selbst gebaut (kein Hub-Image) | Multi-Stage auf node:22-alpine |
| Image (Datenbank) | postgres:16-alpine | Offizielles Image, Tag festgelegt |
| Port | 127.0.0.1:3000:3000 | App + Admin-Panel (/admin) + API (/api) |
| Volume Medien | payload-media:/app/media | Pflicht, sonst gehen Uploads verloren |
| Volume Datenbank | payload-postgres-data:/var/lib/postgresql/data | Pflicht für Datenpersistenz |
| Pflicht-Envs | PAYLOAD_SECRET, DATABASE_URI, POSTGRES_PASSWORD | Alle in .env speichern, nie committen |
| Lizenz | MIT (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:
Dockerfile: Multi-Stage-Build (Schritt 2), ersetzt dasDockerfileder Vorlage.dockerignore: schließtnode_modules,.nextund.envausnext.config.ts: mussoutput: '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.ymlErgä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
distNutzen 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@10ls -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 initialDanach 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 32Legen 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.comDATABASE_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 --buildVerfolgen Sie Build und Start:
$ docker compose logs -fPrüfen Sie den Status beider Container:
$ docker compose psErwartet: 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 FoundSchritt 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/adminGeben 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=50Die 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 302Laden 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 --buildUpdate 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.gzAutomatisierung 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ührtTroubleshooting / 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,
/adminnicht erreichbar HOSTNAME=0.0.0.0fehlt, der Next.js-Server lauscht dann nur im Container auflocalhost.- 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.jsfehltoutput: 'standalone'fehlt innext.config.ts. Ergänzen und neu bauen.- „missing secret key“
PAYLOAD_SECRETist leer oder fehlt in.env. Mitopenssl rand -hex 32erzeugen.- Datenbankverbindung scheitert beim Start
Healthcheck oder
condition: service_healthyfehlt, oder das Passwort enthält Sonderzeichen, die die URI brechen.- Falsche Medien-URLs
NEXT_PUBLIC_SERVER_URLfehlt oder zeigt auf die falsche Domain. Korrigieren und neu bauen.- Build sehr langsam
.dockerignorefehlt,node_moduleslandet im Build-Kontext.EACCESbeim UploadDas Volume gehört root, der Container läuft als
nextjs(UID 1001). Die Meldung lautetEACCES: permission denied, mkdir 'media'. Das Dockerfile legt/app/mediadeshalb vorab mit passendem Besitzer an; das Volume muss auf/app/mediazeigen.- „package.json and package-lock.json … are not in sync“ bei
npm ci Die
.npmrcfehlt im Build. Die Stagedepsmuss sie mitkopieren (.npmrc*in derCOPY-Zeile)."/app/public": not foundbeim BuildDas Projekt hat kein
public/. Die ZeileRUN mkdir -p publicin der Stagebuilderfehlt.ERR_PNPM_IGNORED_BUILDS(esbuild, unrs-resolver)Corepack nutzt pnpm 12. Im Projekt
corepack use pnpm@10ausführen, committen und neu bauen.- „Found multiple config files“
Die
docker-compose.ymlder 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
- Docker Compose: Multi-Container-Stacks aufbauen: Compose-Syntax und Stack-Konzepte vertieft erklärt
- Traefik als Docker-Reverse-Proxy mit automatischem HTTPS: Payload hinter TLS betreiben
- MySQL & PostgreSQL Backup automatisieren mit cron: Payload-Datenbank regelmäßig sichern
- Docker Compose absichern: Secrets, Healthchecks, Non-Root: Produktionshärtung für den Stack
- Directus mit Docker: Alternative Headless-CMS-Lösung mit fertigem Image
Offizielle Quellen: Payload CMS: Production Deployment | Payload CMS auf GitHub | Payload CMS: Migrations


