Object storage and CDN
One Flysystem s3 disk everywhere. Local compose runs community MinIO (API on :9000, console on :9001); production points the same disk at Bunny Storage, AWS S3, or Cloudflare R2. No code changes between the two — only env.
The disk
FILESYSTEM_DISK=s3
AWS_ACCESS_KEY_ID=… # Bunny: storage zone name
AWS_SECRET_ACCESS_KEY=… # Bunny: storage zone password
AWS_BUCKET=… # Bunny: storage zone name
AWS_DEFAULT_REGION=de # Bunny: region CODE (de / ny / …), never a URL
AWS_ENDPOINT=https://de-s3.storage.bunnycdn.com
AWS_URL=https://cdn.example.com # pull zone hostname, or empty for MinIO
Bunny Storage specifics: the S3-compatible API flag is set only at zone creation — there is no per-object ACL and no lifecycle policy. Map the zone name to AWS_ACCESS_KEY_ID + AWS_BUCKET, the zone password to AWS_SECRET_ACCESS_KEY, and the region code (de, ny, …) to AWS_DEFAULT_REGION. AWS_ENDPOINT is https://{region}-s3.storage.bunnycdn.com. The pull zone hostname goes to AWS_URL.
Set retain_visibility=false on the Bunny disk (same as Laravel's R2 special-case). Without it, Flysystem issues a PutObjectAcl / GetObjectAcl round-trip on every copy — Bunny rejects those calls.
Two CDN layers
Both layers are env-gated and default off. Empty value = app origin.
flowchart LR
App[App / Filament] -->|Storage::url / put| S3[Flysystem s3 disk]
S3 -->|local| MinIO[MinIO :9000]
S3 -->|prod| Bunny[(Bunny Storage)]
Bunny --> PullZone[pull zone]
PullZone -->|AWS_URL — object CDN| Browser
Vite[Vite build] -->|asset / mix| AssetCDN[ASSET_URL — asset CDN]
AssetCDN --> Browser
Object CDN — AWS_URL. The public/CDN base in front of the bucket. Storage::url() returns AWS_URL + path. Storage::temporaryUrl() signs against AWS_ENDPOINT (the S3 API host), not the CDN — never treat AWS_URL as a world-readable path for private files. Local MinIO sets AWS_URL=http://localhost:9000/filament; the minio-init sidecar enables anonymous download so unsigned GETs work in the browser.
Asset CDN — ASSET_URL. Prefixes asset() and Vite build URLs at PHP runtime, so one compiled image serves many CDN origins. Leave it empty in dev. Do not set Vite base to a CDN in vite.config.js — that bakes the origin into the build.
Livewire temp uploads
Livewire stashes in-progress uploads on a temp disk before the form submits. On a single-container compose stack, local is fine:
LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=local
On multi-replica Magic Containers the upload request and the submit can land on different pods — set it to s3 so both pods see the same temp file:
LIVEWIRE_TEMPORARY_FILE_UPLOAD_DISK=s3