Cloudflare R2 mit Payload + Railway
Setup-Pattern für persistent Media-Storage bei Payload-CMS-Projekten, die auf Railway laufen. Erstanwendung: Mayday Rhynern 2026-05-04 (Run-Doku).
Warum R2 statt AWS S3
| Aspekt | R2 | AWS S3 |
|---|---|---|
| Egress (Download-Traffic) | 0 € | $0.09/GB |
| Storage | $0.015/GB/Mo | $0.023/GB/Mo |
| EU-Region | ja (eeur location hint) | ja (eu-central-1 etc.) |
| S3-API-kompatibel | ja — @payloadcms/storage-s3 funktioniert direkt | ja, native |
| Setup-Aufwand | ~10 min | ~10 min |
Bei einer Webseite mit Bildern kostet S3 schnell mehr als R2 — jeder Image-Load ist Egress. R2 ist bei statischen Web-Assets fast immer die richtige Wahl.
Voraussetzungen
- Cloudflare-Account
- Wrangler CLI (
brew install cloudflare-wranglerodernpm i -g wrangler) - Payload-Projekt mit
@payloadcms/storage-s3als Plugin - Railway-Service für die App, Service-Name als
<service>im Folgenden
payload.config.ts
import { s3Storage } from '@payloadcms/storage-s3'
export default buildConfig({
// ...
plugins: [
...(process.env.S3_BUCKET
? [
s3Storage({
collections: { media: true },
bucket: process.env.S3_BUCKET,
config: {
credentials: {
accessKeyId: process.env.S3_ACCESS_KEY || '',
secretAccessKey: process.env.S3_SECRET_KEY || '',
},
region: process.env.S3_REGION || 'auto',
endpoint: process.env.S3_ENDPOINT,
forcePathStyle: true,
},
}),
]
: []),
],
})Der process.env.S3_BUCKET ? ... : []-Guard ist wichtig: ohne S3-Vars fällt Payload auf Local-Storage zurück (Folder media/), das in Railway-Containern leer und ephemer ist. Symptom wenn S3-Vars fehlen: File X for collection media is missing on the disk. Expected path: /app/media/X.
Setup-Schritte
1. R2 Bucket erstellen (CLI)
wrangler r2 bucket create <bucket-name> --location=eeur # EUeeur (Eastern Europe) oder weur (Western Europe) für DSGVO-konforme Standorte.
2. API-Token erstellen (Dashboard, nicht CLI)
Wrangler kann keinen S3-API-Token generieren — das geht nur via Cloudflare Dashboard. Direktlink:
https://dash.cloudflare.com/<account-id>/r2/api-tokens
Account-ID kommt aus wrangler whoami.
Klicks:
- „Create API token”
- Permissions: Object Read & Write
- „Specify bucket(s)”: gezielt das eine Bucket (nicht „Apply to all buckets” — schmälere Berechtigung ist besser)
- TTL: leer (forever) oder gewünschte Frist
- „Create API Token”
Cloudflare zeigt drei Werte (nur einmal, also direkt kopieren):
- Access Key ID
- Secret Access Key
- S3 Endpoint (Form:
https://<account-id>.r2.cloudflarestorage.com)
3. Railway ENV-Vars setzen
railway variables --service <service> \
--set "S3_BUCKET=<bucket-name>" \
--set "S3_ACCESS_KEY=<from-dashboard>" \
--set "S3_SECRET_KEY=<from-dashboard>" \
--set "S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com" \
--set "S3_REGION=auto"S3_REGION=auto ist R2-spezifisch (S3 SDK akzeptiert das, R2 ignoriert die Region).
4. Deploy triggern
Setting der Vars triggert in Railway nicht zuverlässig einen Container-Restart. Sicherster Weg ist ein leerer Commit:
git commit --allow-empty -m "chore: trigger redeploy for R2 env vars"
git push5. Re-Seed Media (falls schon DB-Records mit Local-Storage existieren)
Beim Re-Seed laufen die Uploads jetzt durch den S3-Plugin und landen in R2. ENV-Vars müssen auch für das Seed-Skript gesetzt sein:
DATABASE_URL="$(railway variables --service Postgres --kv | grep '^DATABASE_PUBLIC_URL=' | cut -d= -f2-)" \
PAYLOAD_SECRET="$(railway variables --service <service> --kv | grep '^PAYLOAD_SECRET=' | cut -d= -f2-)" \
S3_BUCKET="$(railway variables --service <service> --kv | grep '^S3_BUCKET=' | cut -d= -f2-)" \
S3_ACCESS_KEY="$(railway variables --service <service> --kv | grep '^S3_ACCESS_KEY=' | cut -d= -f2-)" \
S3_SECRET_KEY="$(railway variables --service <service> --kv | grep '^S3_SECRET_KEY=' | cut -d= -f2-)" \
S3_ENDPOINT="$(railway variables --service <service> --kv | grep '^S3_ENDPOINT=' | cut -d= -f2-)" \
S3_REGION="$(railway variables --service <service> --kv | grep '^S3_REGION=' | cut -d= -f2-)" \
npx tsx --tsconfig tsconfig.json src/seed.ts6. Verify
# R2 direkt anpingen (DB-unabhängig)
wrangler r2 object get <bucket-name>/<filename> --file /tmp/test.bin
ls -la /tmp/test.bin # sollte > 0 bytes sein
# Production über die App-URL
curl -sLo /dev/null -w "%{http_code}\n" \
"https://<app-url>/api/media/file/<filename>"
# 200 = funktioniertStolperer
- HEAD vs. GET —
curl -sI(HEAD) auf/api/media/file/<filename>returnt 404 mit JSON-Content-Type. GET (curl -sL) returnt 200 mit Binary. Beim Probieren immer GET nutzen. force-dynamicfür Pages mitgetPayload-Calls — Server-Pages die zur Build-Timepayload.findGlobal(...)oderpayload.find(...)aufrufen failen ohne DB-Connection. Lösung:export const dynamic = 'force-dynamic'oben in der Page.- Auto-Suffix bei Filename-Konflikten — wenn ein Filename in R2 schon existiert (z.B. von altem Local-Storage-Run), benennt Payload neue Uploads als
<name>-<n>.<ext>. Cosmetic only, funktional egal — DB-Record und Frontend-URL sind konsistent. - Variable-Set ohne Restart — Railway restartet bei
railway variables --setnicht garantiert. Empty-Commit + Push als Force. forcePathStyle: trueim s3Storage-Config ist wichtig für R2 (sonst hostname-style URL-Generation und 404).
Wenn S3 nicht aktiv ist — Symptome
[ERROR] File X for collection media is missing on the disk.
Expected path: /app/media/X
Heißt: process.env.S3_BUCKET ist im Container leer. Check:
railway run --service <service> sh -c 'echo "S3_BUCKET=$S3_BUCKET"'Wenn leer → Variable noch nicht im Container. Empty-Commit-Push lösen.
Cleanup-Pattern (falls Demo-Workaround mit committed media/-Files)
Wenn vorher Files ins Repo committed wurden um Container-Local-Storage zu befüllen:
git rm media/*.png media/*.jpg
# Dockerfile: COPY-Zeile für media/ entfernen
git commit -m "chore: R2 läuft — Demo-Workaround entfernt"
git pushRelated
- anthropic-skills.md — andere Setup-Patterns
- Mayday-R2-Run — konkrete Erstanwendung