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

See also

Built by Qcentic