Skip to content

Storage

AWS S3, Cloudflare R2, Backblaze B2, Tigris, MinIO, Garage and SeaweedFS all work. Point the S3_* variables at one:

Terminal window
S3_ENDPOINT=https://s3.eu-central-1.amazonaws.com
S3_REGION=eu-central-1
S3_FORCE_PATH_STYLE=false
Terminal window
S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
S3_REGION=auto
S3_FORCE_PATH_STYLE=true

On start, the server creates the bucket when it is missing, allows browser uploads from APP_URL (a CORS rule), and, when the bucket has no lifecycle rules yet, adds two: staging/ expires after a day, renditions/ after 30. A key that can’t change bucket settings (R2’s object tokens can’t) logs a warning instead: set those three in the provider’s console.

Prefix
assets/{sha256} Originals, by the hash of their bytes. Never overwritten; shared by every project holding the same bytes
previews/{sha256} Stills for files that aren’t images (PDF, PSD, video)
renditions/ Sizes and formats made on request. Expire after 30 days and come back on the next request
staging/{project}/ Browser uploads before they are filed. Expire after a day
sweep-owners/{id} One per database that sweeps the bucket, holding its APP_URL

Back up assets/ and previews/. Renditions and staged uploads are disposable.

Deleting an asset keeps it, restorable, for 30 days. Then the sweeper, which runs in the app every six hours, removes the row, and the original once no asset in any project holds those bytes. Deleting a project or an organization removes its rows at once, and its files at the next sweep. A file written in the last day is never swept, so an upload in flight is safe.

The sweeper removes every original its own database doesn’t hold, so two databases on one bucket (a staging copy, a benchmark, a second install) would remove each other’s files. Give each database its own bucket.

To catch the mistake, each sweep leaves a marker, sweep-owners/{id}, with the id of its database, and deletes nothing while the bucket holds another database’s marker. The server logs a warning naming the markers instead. When a database that shared the bucket is gone for good, delete its marker (it holds the APP_URL of the install that wrote it) and sweeping resumes at the next run.