Zum Hauptinhalt springen

Cloudflare R2 Storage

Postbox nutzt Cloudflare R2 als S3-kompatiblen Object Storage für alle öffentlichen Medien: Profilbilder, Video-Thumbnails und OG-Images. Der Storage ist env-gesteuert umschaltbar zwischen lokalem Filesystem (Entwicklung) und R2 (Produktion).

Wichtig: Alle R2-Buckets verwenden ausschließlich EU-Jurisdiction (Data Residency). Der Endpoint enthält .eu. — ohne dieses Prefix antwortet R2 mit 403 Forbidden.


Architektur

graph TD
A["Laravel App"] -->|"Storage::disk('public')"| B{PUBLIC_STORAGE_DRIVER}
B -->|"local"| C["storage/app/public/<br>Symlink: public/storage/"]
B -->|"s3"| D["Cloudflare R2<br>postbox-media Bucket<br>(EU-Jurisdiction)"]
B -->|"dual-write"| DW["DualWriteAdapter"]
DW -->|"Primary (Reads + Writes)"| D
DW -->|"Secondary (Writes only)"| RF["RustFS<br>(Self-Hosted S3)"]
D -->|"Custom Domain"| E["cdn.postbox.so"]
D -->|"storage:sync-r2-backup"| F["Backup Bucket<br>postbox-backup<br>(EU-Jurisdiction)"]
C -->|"storage:migrate-to-r2"| D
G["Cloudflare GraphQL API"] -->|"cloudflare:fetch-r2-metrics"| H["Cache + DB<br>(r2_metric_snapshots +<br>r2_metric_hourly_snapshots)"]
H -->|"Admin Dashboard"| I["/admin/cloudflare-management"]
H -->|"R2MetricsChart"| J["ApexCharts<br>Storage | Operations | Kosten"]
G -->|"cloudflare:backfill-r2-metrics"| H

R2 Buckets einrichten

Bucket 1: postbox-media (Primary)

  1. Cloudflare Dashboard → R2 Object Storage → Create bucket
  2. Name: postbox-media
  3. Location: EU (Jurisdiction)
  4. Storage Class: Standard

Custom Domain einrichten

  1. Bucket öffnen → SettingsPublic accessCustom Domains
  2. Connect Domain klicken → Domain eingeben (z.B. cdn.postbox.so)
  3. Cloudflare erstellt automatisch CNAME-Record + SSL

Voraussetzung: Die Domain muss als Zone im selben Cloudflare-Account liegen. Public Access wird automatisch aktiviert.

R2 API Token erstellen

  1. Cloudflare Dashboard → R2 → Manage R2 API TokensCreate API Token
  2. Permissions: Object Read & Write
  3. Scope: Auf postbox-media + postbox-backup Buckets beschränken
  4. Access Key ID + Secret Access Key notieren

Cloudflare API Token (Analytics)

Für das Admin Dashboard wird ein separater (oder erweiterter) API Token benötigt:

  1. Cloudflare Dashboard → My ProfileAPI TokensCreate Token
  2. Permissions:
    • Account Analytics: Read (für GraphQL R2 Metriken)
    • Account R2 Storage: Read (für REST /r2/metrics)
    • Billing: Read (optional, für Billing-Abgleich)
  3. Account ID notieren

CORS-Policy konfigurieren

  1. Bucket öffnen → SettingsCORS PolicyAdd/Edit CORS policy
  2. Folgende JSON-Konfiguration eintragen:
[
{
"AllowedOrigins": [
"https://app.postbox.so"
],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
FeldWertBegründung
AllowedOriginshttps://app.postbox.soNur die App-Domain, kein Wildcard *
AllowedMethodsGET, HEADLese-Zugriff reicht — kein PUT/DELETE vom Browser
AllowedHeaders*Erlaubt alle Request-Headers (z.B. Range für Partial Requests)
MaxAgeSeconds86400Browser cacht Preflight-Response 24 Stunden

Sicherheitshinweis: Kein "AllowedOrigins": ["*"] verwenden — das würde externem JavaScript erlauben, Bucket-Inhalte per fetch() zu laden (Hotlinking via JS).


Bucket 2: postbox-backup (Backup)

  1. Create bucket → Name: postbox-backup
  2. Location: EU (gleiche Jurisdiction wie Primary)
  3. Kein Public Access — kein Custom Domain nötig
  4. Wird nur server-seitig über storage:sync-r2-backup befüllt
  5. Kann denselben API Token verwenden

.env Konfiguration

# R2 Primary Bucket (Medien-Storage: Profilbilder, Video-Thumbnails, OG-Images)
PUBLIC_STORAGE_DRIVER=s3
R2_ACCESS_KEY_ID=dein_access_key_id
R2_SECRET_ACCESS_KEY=dein_secret_access_key
R2_BUCKET=postbox-media
R2_ENDPOINT=https://ACCOUNT_ID.eu.r2.cloudflarestorage.com

# Öffentliche URL (Custom Domain des R2 Buckets — nur für URL-Generierung, nicht für S3-API)
PUBLIC_STORAGE_URL=https://cdn.postbox.so

# S3 URL-Schema: true = path-style (default, empfohlen für R2), false = virtual-hosted
# R2_USE_PATH_STYLE_ENDPOINT=true

# R2 Backup Bucket (Disaster Recovery, kein Public Access)
R2_BACKUP_ACCESS_KEY_ID=dein_access_key_id
R2_BACKUP_SECRET_ACCESS_KEY=dein_secret_access_key
R2_BACKUP_BUCKET=postbox-backup
R2_BACKUP_ENDPOINT=https://ACCOUNT_ID.eu.r2.cloudflarestorage.com

# Cloudflare API (für R2-Metriken im Admin Dashboard)
CLOUDFLARE_API_TOKEN=dein_api_token
CLOUDFLARE_ACCOUNT_ID=dein_account_id

# EU-Jurisdiction: 'eu' prefix wird für GraphQL-Queries an Bucket-Namen angehängt
# Ohne dieses Prefix liefert GraphQL keine Daten für EU-Jurisdiction Buckets
R2_JURISDICTION=eu

# --- Optional: Dual-Write zu RustFS (Self-Hosted S3) ---
# Aktivierung: PUBLIC_STORAGE_DRIVER=dual-write (statt s3)
# Alle Writes gehen parallel zu R2 + RustFS, Reads nur von R2.
# Fehler beim RustFS-Write werden geloggt, aber nicht propagiert.

# RustFS S3-Credentials
RUSTFS_ACCESS_KEY_ID=dein_rustfs_access_key
RUSTFS_SECRET_ACCESS_KEY=dein_rustfs_secret_key
RUSTFS_BUCKET=postbox-media
RUSTFS_ENDPOINT=https://rustfs.dein-server.com
# AWS SDK Pflichtfeld, wird von Self-Hosted S3 (RustFS/MinIO) ignoriert
# RUSTFS_REGION=us-east-1
# RUSTFS_USE_PATH_STYLE_ENDPOINT=true

# Welcher Disk als Mirror-Ziel (default: rustfs)
# STORAGE_MIRROR_DISK=rustfs

Die R2_ENDPOINT URL findet man unter: Cloudflare Dashboard → R2 → Bucket → SettingsS3 API.

WICHTIG — EU Data Residency: Alle Buckets verwenden ausschließlich EU-Jurisdiction. Der Endpoint enthält .eu. Subdomain:

JurisdictionEndpoint-Format
Automatic/Globalhttps://<account-id>.r2.cloudflarestorage.com
EU (verwendet)https://<account-id>.eu.r2.cloudflarestorage.com

Wenn der Endpoint ohne .eu. verwendet wird, antwortet R2 mit 403 Forbidden. Die GraphQL API benötigt zusätzlich den eu_ Prefix vor dem Bucket-Namen (automatisch durch R2_JURISDICTION=eu).


Filesystem-Konfiguration

Location: config/filesystems.php

Disks

DiskDriverZweck
publiclocal, s3 oder dual-write (env-gesteuert)Primary Storage fuer alle oeffentlichen Dateien
rustfss3 (fix)Self-Hosted S3-Backup, nur als Mirror-Ziel fuer dual-write
local-publiclocal (fix)Nur fuer storage:migrate-to-r2 — liest immer vom lokalen Filesystem
r2-backups3 (fix)Backup-Bucket, nur fuer storage:sync-r2-backup

Dual-Write Modus

Der dual-write Driver (DualWriteFilesystemAdapter) ermoeglicht transparente Replikation zu einem zweiten S3-kompatiblen Backend (z.B. RustFS). Aktivierung per PUBLIC_STORAGE_DRIVER=dual-write.

Verhalten:

  • Reads: Nur von Primary (R2)
  • Writes (write, delete, move, copy, createDirectory, setVisibility): Gehen an beide
  • Fehler am Secondary: Werden geloggt (Log::warning), aber nicht propagiert — Primary ist autoritativ
  • Streams: writeStream() buffert den Stream-Inhalt, da ein Stream nur einmal gelesen werden kann

Location: app/Storage/DualWriteFilesystemAdapter.php, registriert in AppServiceProvider::boot()


Commands

CommandBeschreibungSchedule
cloudflare:fetch-r2-metricsR2-Metriken via Cloudflare GraphQL/REST API abrufen + Hourly SnapshotStündlich
cloudflare:backfill-r2-metricsHistorische stündliche R2-Snapshots aus Cloudflare GraphQL nachfüllen (bis 31 Tage)Manuell
storage:migrate-to-r2Lokale Dateien nach R2 migrierenManuell
storage:verify-r2R2-Integrität gegen lokale Dateien prüfenManuell
storage:sync-r2-backupPrimary → Backup Bucket SyncSonntag 04:30

cloudflare:fetch-r2-metrics

Ersetzt den alten cloudflare:snapshot-metrics Command (entfernt). Statt 820K+ Objekte via S3 ListObjectsV2 zu scannen (~821 API-Calls, 5-10 Min), werden 3 leichtgewichtige API-Calls ausgeführt (~3-5 Sekunden):

SchrittAPIDaten
1. REST /r2/metricsAccount-Level Storage (Standard + Infrequent Access)~1s
2. GraphQL r2StorageAdaptiveGroupsPer-Bucket Storage Snapshot (Objects, Payload, Metadata)~1-3s
3. GraphQL r2OperationsAdaptiveGroupsOperations nach actionType (30d) mit Class A/B Mapping~1-3s

Zusätzlich:

  • Backup-Bucket Storage (GraphQL)
  • Anomalie-Erkennung (>20% Abweichung → Admin-Notification)
  • Täglicher DB-Snapshot in r2_metric_snapshots
  • Stündlicher DB-Snapshot in r2_metric_hourly_snapshots (für Charts)
  • Storage-Jump-Alert (E3): >5% Änderung innerhalb 1 Stunde → Admin-Notification
  • Heartbeat-Aufzeichnung (cron:heartbeat:r2_metrics)

cloudflare:backfill-r2-metrics

Füllt historische stündliche R2-Snapshots aus Cloudflare GraphQL nach (bis 31 Tage Retention). Nützlich nach Erstinstallation oder wenn stündliche Daten fehlen.

# Alle 31 Tage backfüllen (Standard)
php artisan cloudflare:backfill-r2-metrics

# Nur letzte 7 Tage
php artisan cloudflare:backfill-r2-metrics --days=7
  • Nutzt r2StorageAdaptiveGroups mit dimensions: [datetimeHour] für Per-Stunde-Daten
  • Upsert mit uniqueBy: [recorded_at, bucket_name] — Duplikate werden übersprungen
  • Operations-Daten (Class A/B) sind nicht über das Backfill verfügbar (nur Storage-Daten)

Vergleich alt vs. neu:

MetrikAlt (S3 Scan)Neu (API)
API-Calls pro Scan~8213-5
Laufzeit5-10 Min3-5 Sek
Memory-Peak~50-80 MB~2-5 MB
Schedule2x täglichStündlich

CloudflareR2Service

Location: app/Services/Cloudflare/CloudflareR2Service.php

Service für R2-Bucket-Metriken im Admin Dashboard. Nutzt Cloudflare GraphQL + REST APIs.

API-Methoden

MethodReturnBeschreibung
fetchBucketStorage(bucket)array|nullPer-Bucket Storage via GraphQL (Objects, Size, Metadata)
fetchOperationMetrics(bucket, days)array|nullOperations nach actionType via GraphQL mit Class A/B Mapping
fetchAccountMetrics()array|nullAccount-Level Storage via REST (Standard + Infrequent Access)
fetchBackupBucketStorage()array|nullBackup-Bucket Storage via GraphQL
fetchHourlyStorageData(bucket, days)array|nullPer-Stunde Storage via GraphQL (für Backfill)
calculateMonthlyCost(storage, ops)arrayKosten-Berechnung mit echten Class A/B Daten

Cache-Accessors (für Dashboard)

MethodReturnBeschreibung
getBucketSizeMb()float|nullBucket-Größe in MB (1h Cache)
getObjectCount()int|nullAnzahl Objekte (1h Cache)
getOperations()array|nullOperations-Daten mit Class A/B
getCostEstimate()array|nullKosten-Schätzung
getAnomalies()arrayErkannte Anomalien

Health & Config

MethodReturnBeschreibung
isR2Active()boolPrüft ob R2 konfiguriert ist
isHealthy()boolR2-Erreichbarkeit prüfen
getLatencyMs()floatR2-Latenz messen
hasApiCredentials()boolCloudflare API Token + Account ID vorhanden
getBackupStatus()arrayBackup-Bucket Health + letzter Sync

Anomalie-Erkennung (E2)

MethodReturnBeschreibung
detectAnomalies(bucket, storage, ops)arrayVergleich mit vorherigem Tag, Warnung bei >20% Abweichung

Prüft: Object Count, Storage-Größe, Total Requests. Bei Anomalien wird eine Admin-Notification gesendet.

Storage-Prognose (E5)

MethodReturnBeschreibung
getStorageForecast(bucket, months)array|nullLineare Regression auf 30 Tage DB-Snapshots

Gibt zurück: aktuell GB, prognostiziert GB, tägliches Wachstum, prognostizierte Kosten.

Billing-Vergleich (E4)

MethodReturnBeschreibung
getBillingComparison(bucket)array|nullMonatsvergleich (aktuell vs. Vormonat), Warnung bei >10% Abweichung

Class A/B Operation Mapping

Operations werden nach Cloudflare R2 Pricing Docs klassifiziert:

KlasseOperationsPreis
Class A (mutating)PutObject, CopyObject, ListObjects, DeleteObject etc.$4.50/1M
Class B (read-only)GetObject, HeadObject, HeadBucket etc.$0.36/1M

Admin Dashboard

Route: /admin/cloudflare-management Component: App\Livewire\Admin\CloudflareManagement\Index

Widgets

WidgetDatenquelleBeschreibung
KPI-BoxenCacheHealth, Latenz, Storage, Objects, Kosten, Bucket, CDN URL
Operations-BreakdownCache (GraphQL)Tabelle: actionType, Requests, Class A/B
Kosten-BreakdownCacheStorage, Class A, Class B, Egress, Total
Monatsvergleich (E4)DBKostenvergleich aktueller vs. Vormonat
Storage-Prognose (E5)DBLineare Projektion: 6 Monate, GB + Kosten
R2 Verlauf (Chart)DB (Hourly + Daily)Interaktiver ApexCharts-Chart mit Tabs: Storage, Operations, Kosten
Backup-Bucket (E3)Cache + GraphQLHealth, Sync-Status, Objects, Größe
Account StorageCache (REST)Standard + Infrequent Access über alle Buckets
Anomalie-Alert (E2)CacheWarnbanner bei >20% Abweichung
KonfigurationConfigENV-Werte (Driver, URL, Bucket, Token, Jurisdiction)

Aktualisieren-Button

Der Button triggert cloudflare:fetch-r2-metrics --force als detachierten OS-Prozess. Auto-Polling alle 5 Sekunden bis der Abruf abgeschlossen ist. Timeout: Nach 60 Sekunden ohne neuen Snapshot wird ein Fehler-Toast angezeigt und das Polling beendet.

Error-Reporting

Alle API-Fehler (GraphQL + REST) werden via report() an Flare/Nightwatch gemeldet. Das Command hat einen Top-level try/catch der auch unerwartete Exceptions abfängt und meldet. Logs landen in storage/logs/laravel.log (Log-Facade) und storage/logs/r2-metrics.log (Console-Output bei manueller Aktualisierung via exec).

R2 Verlauf-Chart (R2MetricsChart)

Component: App\Livewire\Admin\CloudflareManagement\R2MetricsChart

Interaktiver Multi-Tab Chart mit ApexCharts (CDN) und Alpine.js x-data + wire:ignore Pattern. Ersetzt die früheren statischen Storage-Trend und Request-Trend Tabellen.

Tabs

TabDatenChart-Typ
StorageStandard Storage (GB) + Infrequent Access (E9)Line
OperationsClass A + Class B Requests (Delta-Berechnung, E1)Stacked Bar
KostenGeschätzte Kosten ($/Monat)Line

Zeiträume

ZeitraumDatenquelleGranularität
1 Tagr2_metric_hourly_snapshotsStündlich
7 Tager2_metric_hourly_snapshotsStündlich
30 Tager2_metric_snapshotsTäglich
1 Jahrr2_metric_snapshotsTäglich

Features

  • E1 — Operations-Delta: Cloudflare-Operations sind kumulative 30d-Werte. Der Chart berechnet Deltas zwischen aufeinanderfolgenden Snapshots, um Operations pro Stunde/Tag darzustellen. Negative Deltas (durch 30d-Fenster-Verschiebung) werden auf 0 geclampt.
  • E2 — Kosten-Prognose: Für 30d und 1y Ansichten wird eine gestrichelte Prognoselinie via linearer Regression berechnet (30% der Datenpunkte in die Zukunft projiziert).
  • E9 — Infrequent Access: Im Storage-Tab wird eine zusätzliche IA-Serie angezeigt, wenn IA-Daten vorhanden sind (nur in stündlichen Ansichten).

DB-Tabelle: r2_metric_snapshots

Tägliche Snapshots für Langzeit-Trends (GraphQL hat nur 31 Tage Retention).

SpalteTypBeschreibung
dateDATETag des Snapshots
bucket_nameVARCHAR(100)z.B. "postbox-media", "postbox-backup"
object_countBIGINTAnzahl Objekte
payload_size_bytesBIGINTGesamtgröße der Objekte
metadata_size_bytesBIGINTGesamtgröße der Metadaten
class_a_requestsBIGINTClass A Requests (Tag)
class_b_requestsBIGINTClass B Requests (Tag)
total_requestsBIGINTGesamt-Requests (Tag)
operations_by_typeJSONBDetail nach actionType
estimated_cost_usdDECIMAL(8,4)Berechnete Tageskosten

Retention: 10 Jahre (via MassPrunable). ~146 KB/Jahr pro Bucket.


DB-Tabelle: r2_metric_hourly_snapshots

Stündliche Snapshots für hochauflösende Charts (1d/7d Ansichten). Werden bei jedem cloudflare:fetch-r2-metrics Lauf geschrieben.

SpalteTypBeschreibung
recorded_atTIMESTAMPTZStunde des Snapshots (startOfHour)
bucket_nameVARCHAR(100)z.B. "postbox-media", "postbox-backup"
object_countBIGINTAnzahl Objekte
payload_size_bytesBIGINTGesamtgröße der Objekte
metadata_size_bytesBIGINTGesamtgröße der Metadaten
upload_countBIGINTUpload-Zähler
ia_object_countBIGINTInfrequent Access Objekte (E9)
ia_payload_size_bytesBIGINTInfrequent Access Größe (E9)
class_a_requestsBIGINTClass A Requests (kumulativ 30d)
class_b_requestsBIGINTClass B Requests (kumulativ 30d)
total_requestsBIGINTGesamt-Requests (kumulativ 30d)
estimated_cost_usdDECIMAL(8,4)Berechnete Kosten zum Zeitpunkt

Unique Constraint: [recorded_at, bucket_name] — verhindert doppelte Einträge per Upsert.

Retention: 1 Jahr (via MassPrunable).

Pruning Schedule:

TabellePrune-ZeitpunktRetention
r2_metric_hourly_snapshotsTäglich 03:25 UTC1 Jahr
r2_metric_snapshotsTäglich 03:30 UTC10 Jahre

R2 Pricing (Stand 2025)

KomponentePreisFreibetrag
Storage$0.015/GB/Monat10 GB
Class A (PUT, POST, LIST)$4.50/1M Requests1M
Class B (GET, HEAD)$0.36/1M Requests10M
Egresskostenlosunbegrenzt

Vergleich: Lokal vs. R2

AspektLokal (Dev)R2 (Produktion)
ConfigPUBLIC_STORAGE_DRIVER=localPUBLIC_STORAGE_DRIVER=s3
Pfad-Basisstorage/app/public/R2 Bucket via S3-API
URL-BasisAPP_URL/storage/cdn.postbox.so/
Migrationstorage:migrate-to-r2
BackupManuellstorage:sync-r2-backup (wöchentlich)
Cache HeadersDefaultImmutable (max-age=31536000)
CDNKeinCloudflare (Custom Domain)
MonitoringFilesystemGraphQL/REST API + Admin Dashboard