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

AspektR2AWS S3
Egress (Download-Traffic)0 €$0.09/GB
Storage$0.015/GB/Mo$0.023/GB/Mo
EU-Regionja (eeur location hint)ja (eu-central-1 etc.)
S3-API-kompatibelja — @payloadcms/storage-s3 funktioniert direktja, 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-wrangler oder npm i -g wrangler)
  • Payload-Projekt mit @payloadcms/storage-s3 als 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  # EU

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

  1. „Create API token”
  2. Permissions: Object Read & Write
  3. „Specify bucket(s)”: gezielt das eine Bucket (nicht „Apply to all buckets” — schmälere Berechtigung ist besser)
  4. TTL: leer (forever) oder gewünschte Frist
  5. „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 push

5. 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.ts

6. 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 = funktioniert

Stolperer

  • HEAD vs. GETcurl -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-dynamic für Pages mit getPayload-Calls — Server-Pages die zur Build-Time payload.findGlobal(...) oder payload.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 --set nicht garantiert. Empty-Commit + Push als Force.
  • forcePathStyle: true im 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 push