AWS S3, Cloudflare R2, MinIO on a box in the office - anything that speaks the S3 REST API. Plain fetch against it, no SDK, your own credentials.
| Field | What goes there |
|---|---|
| bucket | Required |
| region | Required, e.g. us-east-1 |
| endpoint | Only for a non-AWS host - R2, MinIO, a self-hosted gateway |
| prefix | Optional - if drafts do not sit at the bucket's root |
| access key id / secret access key | Required, sent as signed headers - never as a query string, never through an SDK's own credential chain |
Anywhere, given the bucket's own CORS configuration allows the origin this app is running from. Without it, the browser refuses the request before it is even sent, and reports nothing more specific than Failed to fetch - almost always this, not a credential or a typo.
The bucket's CORS rules need to allow this origin for GET, PUT and DELETE, allow the authorization and x-amz-* request headers, and expose etag and x-amz-bucket-region. On AWS, as a bucket CORS policy:
[
{
"AllowedOrigins": ["https://app.dev.review"],
"AllowedMethods": ["GET", "PUT", "DELETE"],
"AllowedHeaders": ["authorization", "x-amz-*"],
"ExposeHeaders": ["etag", "x-amz-bucket-region"]
}
]
No SDK: the four verbs this needs are four verbs, and an SDK would arrive wanting a credential chain that reads files and environment variables that do not exist in a browser. Everything here is a signed fetch against the plain REST API, which is also why any S3-compatible host works rather than only AWS's own.
Every listed object carries the entity tag S3 hands back on write, so two writes of the same length in the same millisecond are still told apart - the one backend among the five that gets this precision for free, rather than falling back to a timestamp.