File storage
Bun's native S3 client, MinIO locally, Cloudflare R2 in production, presigned uploads throughout.
Uploads never pass through the API. The browser asks for a short-lived URL, then PUTs the bytes straight at object storage, and tells the API when it is done. The server signs and records; it does not carry the file.
There is no AWS SDK in the dependency tree. Bun's S3Client handles
signing, presigning and uploads natively, and the same client talks to MinIO
and to R2 with nothing changed but an endpoint.
Two backends, one code path
docker compose up brings up MinIO alongside Postgres and Redis. Point the
S3_* variables at it, copying the values from .env.example:
S3_ENDPOINT=http://localhost:9000
S3_ACCESS_KEY_ID=...
S3_SECRET_ACCESS_KEY=...
S3_BUCKET_NAME=...
S3_REGION=auto # optionalStorage used to be R2-or-nothing, which meant a fresh clone had no working uploads until the buyer opened a Cloudflare account. That is a poor first hour for a boilerplate whose promise is that it runs.
R2_ACCOUNT_ID=...
R2_ACCESS_KEY_ID=...
R2_SECRET_ACCESS_KEY=...
R2_BUCKET_NAME=...Any S3-compatible provider works; R2 is the default because egress is free and the client is the same.
S3_ENDPOINT wins when both are set. A developer with leftover R2 credentials
in their shell gets the local container rather than writing test objects into
the production bucket, which is the failure this ordering exists to prevent.
Neither configured, and getStorage() throws StorageNotConfiguredError with
the variable names in the message rather than a stack trace about undefined.
The upload flow
POST /api/v1/files/presign returns a short-lived PUT URL for the object
key the caller asked about. The user id comes from the session, never from the
body.
The browser PUTs the file to that URL. The API is not in this path, so a 400 MB upload does not occupy a request handler for its duration and does not count against any body-size limit.
The client calls the confirm endpoint, which records the object against the user. Until this happens the object exists in the bucket with nothing pointing at it.
That last step is where the interesting failure lives: a client that uploads and then closes the tab leaves an orphan. If that matters for your billing or your quota, reconcile the bucket against the table on a schedule; a background job is the natural home for it.
Style differences that matter
MinIO serves buckets as a path segment while R2 and S3 use a host prefix, so
the client sets virtualHostedStyle: false for the endpoint case. If you point
S3_ENDPOINT at a provider that expects virtual-hosted style, that flag is the
one to change.
resetStorage() exists for tests: the client is memoised, and environment
changes are otherwise not picked up between cases.