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)
- Cloudflare Dashboard → R2 Object Storage → Create bucket
- Name:
postbox-media - Location: EU (Jurisdiction)
- Storage Class: Standard
Custom Domain einrichten
- Bucket öffnen → Settings → Public access → Custom Domains
- Connect Domain klicken → Domain eingeben (z.B.
cdn.postbox.so) - 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
- Cloudflare Dashboard → R2 → Manage R2 API Tokens → Create API Token
- Permissions: Object Read & Write
- Scope: Auf
postbox-media+postbox-backupBuckets beschränken - 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:
- Cloudflare Dashboard → My Profile → API Tokens → Create Token
- 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)
- Account ID notieren
CORS-Policy konfigurieren
- Bucket öffnen → Settings → CORS Policy → Add/Edit CORS policy
- Folgende JSON-Konfiguration eintragen:
[
{
"AllowedOrigins": [
"https://app.postbox.so"
],
"AllowedMethods": ["GET", "HEAD"],
"AllowedHeaders": ["*"],
"MaxAgeSeconds": 86400
}
]
| Feld | Wert | Begründung |
|---|---|---|
AllowedOrigins | https://app.postbox.so | Nur die App-Domain, kein Wildcard * |
AllowedMethods | GET, HEAD | Lese-Zugriff reicht — kein PUT/DELETE vom Browser |
AllowedHeaders | * | Erlaubt alle Request-Headers (z.B. Range für Partial Requests) |
MaxAgeSeconds | 86400 | Browser 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)
- Create bucket → Name:
postbox-backup - Location: EU (gleiche Jurisdiction wie Primary)
- Kein Public Access — kein Custom Domain nötig
- Wird nur server-seitig über
storage:sync-r2-backupbefüllt - 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 → Settings → S3 API.
WICHTIG — EU Data Residency: Alle Buckets verwenden ausschließlich EU-Jurisdiction. Der Endpoint enthält .eu. Subdomain:
| Jurisdiction | Endpoint-Format |
|---|---|
| Automatic/Global | https://<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
| Disk | Driver | Zweck |
|---|---|---|
public | local, s3 oder dual-write (env-gesteuert) | Primary Storage fuer alle oeffentlichen Dateien |
rustfs | s3 (fix) | Self-Hosted S3-Backup, nur als Mirror-Ziel fuer dual-write |
local-public | local (fix) | Nur fuer storage:migrate-to-r2 — liest immer vom lokalen Filesystem |
r2-backup | s3 (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
| Command | Beschreibung | Schedule |
|---|---|---|
cloudflare:fetch-r2-metrics | R2-Metriken via Cloudflare GraphQL/REST API abrufen + Hourly Snapshot | Stündlich |
cloudflare:backfill-r2-metrics | Historische stündliche R2-Snapshots aus Cloudflare GraphQL nachfüllen (bis 31 Tage) | Manuell |
storage:migrate-to-r2 | Lokale Dateien nach R2 migrieren | Manuell |
storage:verify-r2 | R2-Integrität gegen lokale Dateien prüfen | Manuell |
storage:sync-r2-backup | Primary → Backup Bucket Sync | Sonntag 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):
| Schritt | API | Daten |
|---|---|---|
1. REST /r2/metrics | Account-Level Storage (Standard + Infrequent Access) | ~1s |
2. GraphQL r2StorageAdaptiveGroups | Per-Bucket Storage Snapshot (Objects, Payload, Metadata) | ~1-3s |
3. GraphQL r2OperationsAdaptiveGroups | Operations 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
r2StorageAdaptiveGroupsmitdimensions: [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:
| Metrik | Alt (S3 Scan) | Neu (API) |
|---|---|---|
| API-Calls pro Scan | ~821 | 3-5 |
| Laufzeit | 5-10 Min | 3-5 Sek |
| Memory-Peak | ~50-80 MB | ~2-5 MB |
| Schedule | 2x täglich | Stündlich |
CloudflareR2Service
Location: app/Services/Cloudflare/CloudflareR2Service.php
Service für R2-Bucket-Metriken im Admin Dashboard. Nutzt Cloudflare GraphQL + REST APIs.
API-Methoden
| Method | Return | Beschreibung |
|---|---|---|
fetchBucketStorage(bucket) | array|null | Per-Bucket Storage via GraphQL (Objects, Size, Metadata) |
fetchOperationMetrics(bucket, days) | array|null | Operations nach actionType via GraphQL mit Class A/B Mapping |
fetchAccountMetrics() | array|null | Account-Level Storage via REST (Standard + Infrequent Access) |
fetchBackupBucketStorage() | array|null | Backup-Bucket Storage via GraphQL |
fetchHourlyStorageData(bucket, days) | array|null | Per-Stunde Storage via GraphQL (für Backfill) |
calculateMonthlyCost(storage, ops) | array | Kosten-Berechnung mit echten Class A/B Daten |
Cache-Accessors (für Dashboard)
| Method | Return | Beschreibung |
|---|---|---|
getBucketSizeMb() | float|null | Bucket-Größe in MB (1h Cache) |
getObjectCount() | int|null | Anzahl Objekte (1h Cache) |
getOperations() | array|null | Operations-Daten mit Class A/B |
getCostEstimate() | array|null | Kosten-Schätzung |
getAnomalies() | array | Erkannte Anomalien |
Health & Config
| Method | Return | Beschreibung |
|---|---|---|
isR2Active() | bool | Prüft ob R2 konfiguriert ist |
isHealthy() | bool | R2-Erreichbarkeit prüfen |
getLatencyMs() | float | R2-Latenz messen |
hasApiCredentials() | bool | Cloudflare API Token + Account ID vorhanden |
getBackupStatus() | array | Backup-Bucket Health + letzter Sync |
Anomalie-Erkennung (E2)
| Method | Return | Beschreibung |
|---|---|---|
detectAnomalies(bucket, storage, ops) | array | Vergleich 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)
| Method | Return | Beschreibung |
|---|---|---|
getStorageForecast(bucket, months) | array|null | Lineare Regression auf 30 Tage DB-Snapshots |
Gibt zurück: aktuell GB, prognostiziert GB, tägliches Wachstum, prognostizierte Kosten.
Billing-Vergleich (E4)
| Method | Return | Beschreibung |
|---|---|---|
getBillingComparison(bucket) | array|null | Monatsvergleich (aktuell vs. Vormonat), Warnung bei >10% Abweichung |
Class A/B Operation Mapping
Operations werden nach Cloudflare R2 Pricing Docs klassifiziert:
| Klasse | Operations | Preis |
|---|---|---|
| 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
| Widget | Datenquelle | Beschreibung |
|---|---|---|
| KPI-Boxen | Cache | Health, Latenz, Storage, Objects, Kosten, Bucket, CDN URL |
| Operations-Breakdown | Cache (GraphQL) | Tabelle: actionType, Requests, Class A/B |
| Kosten-Breakdown | Cache | Storage, Class A, Class B, Egress, Total |
| Monatsvergleich (E4) | DB | Kostenvergleich aktueller vs. Vormonat |
| Storage-Prognose (E5) | DB | Lineare Projektion: 6 Monate, GB + Kosten |
| R2 Verlauf (Chart) | DB (Hourly + Daily) | Interaktiver ApexCharts-Chart mit Tabs: Storage, Operations, Kosten |
| Backup-Bucket (E3) | Cache + GraphQL | Health, Sync-Status, Objects, Größe |
| Account Storage | Cache (REST) | Standard + Infrequent Access über alle Buckets |
| Anomalie-Alert (E2) | Cache | Warnbanner bei >20% Abweichung |
| Konfiguration | Config | ENV-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
| Tab | Daten | Chart-Typ |
|---|---|---|
| Storage | Standard Storage (GB) + Infrequent Access (E9) | Line |
| Operations | Class A + Class B Requests (Delta-Berechnung, E1) | Stacked Bar |
| Kosten | Geschätzte Kosten ($/Monat) | Line |
Zeiträume
| Zeitraum | Datenquelle | Granularität |
|---|---|---|
| 1 Tag | r2_metric_hourly_snapshots | Stündlich |
| 7 Tage | r2_metric_hourly_snapshots | Stündlich |
| 30 Tage | r2_metric_snapshots | Täglich |
| 1 Jahr | r2_metric_snapshots | Tä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).
| Spalte | Typ | Beschreibung |
|---|---|---|
| date | DATE | Tag des Snapshots |
| bucket_name | VARCHAR(100) | z.B. "postbox-media", "postbox-backup" |
| object_count | BIGINT | Anzahl Objekte |
| payload_size_bytes | BIGINT | Gesamtgröße der Objekte |
| metadata_size_bytes | BIGINT | Gesamtgröße der Metadaten |
| class_a_requests | BIGINT | Class A Requests (Tag) |
| class_b_requests | BIGINT | Class B Requests (Tag) |
| total_requests | BIGINT | Gesamt-Requests (Tag) |
| operations_by_type | JSONB | Detail nach actionType |
| estimated_cost_usd | DECIMAL(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.
| Spalte | Typ | Beschreibung |
|---|---|---|
| recorded_at | TIMESTAMPTZ | Stunde des Snapshots (startOfHour) |
| bucket_name | VARCHAR(100) | z.B. "postbox-media", "postbox-backup" |
| object_count | BIGINT | Anzahl Objekte |
| payload_size_bytes | BIGINT | Gesamtgröße der Objekte |
| metadata_size_bytes | BIGINT | Gesamtgröße der Metadaten |
| upload_count | BIGINT | Upload-Zähler |
| ia_object_count | BIGINT | Infrequent Access Objekte (E9) |
| ia_payload_size_bytes | BIGINT | Infrequent Access Größe (E9) |
| class_a_requests | BIGINT | Class A Requests (kumulativ 30d) |
| class_b_requests | BIGINT | Class B Requests (kumulativ 30d) |
| total_requests | BIGINT | Gesamt-Requests (kumulativ 30d) |
| estimated_cost_usd | DECIMAL(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:
| Tabelle | Prune-Zeitpunkt | Retention |
|---|---|---|
r2_metric_hourly_snapshots | Täglich 03:25 UTC | 1 Jahr |
r2_metric_snapshots | Täglich 03:30 UTC | 10 Jahre |
R2 Pricing (Stand 2025)
| Komponente | Preis | Freibetrag |
|---|---|---|
| Storage | $0.015/GB/Monat | 10 GB |
| Class A (PUT, POST, LIST) | $4.50/1M Requests | 1M |
| Class B (GET, HEAD) | $0.36/1M Requests | 10M |
| Egress | kostenlos | unbegrenzt |
Vergleich: Lokal vs. R2
| Aspekt | Lokal (Dev) | R2 (Produktion) |
|---|---|---|
| Config | PUBLIC_STORAGE_DRIVER=local | PUBLIC_STORAGE_DRIVER=s3 |
| Pfad-Basis | storage/app/public/ | R2 Bucket via S3-API |
| URL-Basis | APP_URL/storage/ | cdn.postbox.so/ |
| Migration | — | storage:migrate-to-r2 |
| Backup | Manuell | storage:sync-r2-backup (wöchentlich) |
| Cache Headers | Default | Immutable (max-age=31536000) |
| CDN | Kein | Cloudflare (Custom Domain) |
| Monitoring | Filesystem | GraphQL/REST API + Admin Dashboard |