Written endpoint-first, not AWS-first. endpoint plus forcePathStyle is
the whole difference between AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces,
Backblaze B2, Wasabi, Scaleway, Hetzner, Supabase Storage and Tigris.
One adapter reaches all of them, which is why this is one package and not ten.
npm install @apograph/media-provider-s3import { createS3StorageProvider } from '@apograph/media-provider-s3';
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});Configuration
{ bucket, region?, endpoint?, forcePathStyle?, credentials?, keyPrefix?, client? }
| Variable | Default | What it does |
|---|---|---|
MEDIA_S3_BUCKET | — | Required |
MEDIA_S3_REGION | auto | What R2 expects. AWS needs its real region |
MEDIA_S3_ENDPOINT | — | Omit for AWS itself; set it for everything else |
MEDIA_S3_FORCE_PATH_STYLE | false | true for MinIO and anything needing path-style URLs |
MEDIA_S3_ACCESS_KEY_ID | — | Omit on a host with an instance role |
MEDIA_S3_SECRET_ACCESS_KEY | — | Omit with the above |
region defaults to auto because that is what R2 expects. Most other services
ignore the value, but the request signer still needs one.
Omit credentials rather than blanking them
Absent means “use the SDK’s own provider chain” — an instance role, IRSA, a shared config file. An object of blank strings shadows that chain with credentials that cannot sign, and the failure surfaces as a 403 nobody can trace back to the configuration.
Set both variables, or neither.
Capabilities
{ directUrl: true, contentTypeMetadata: true, streamingPut: true } — the only
shipped provider that declares directUrl unconditionally.
That is what makes MEDIA_DIRECT_SERVE=signed-url available here, and it is
earned rather than asserted: the signed URL pins ResponseContentDisposition
and ResponseContentType.
Why pinning those headers is the requirement
A redirect discards the app’s own Content-Disposition, nosniff and CSP, and
the stored MIME type is the uploader’s unverified claim.
Without the pin, an uploaded .html served inline from the bucket is stored XSS
on the bucket’s origin. A backend that cannot pin them must declare
directUrl: false and be proxied.
Four things not to lose in a refactor
put is all-or-nothing. A multipart upload that fails leaves parts billed,
invisible to ListObjects, and unreachable — the key was never returned, so the
core’s reclaim loop cannot see them. Aborting the upload in the catch is the
only thing that removes them.
A source error must reach the meter. The body is piped through a pass the uploader reads; without the forwarded error, an upload whose source dies waits forever on a stream that will never end — a request that never answers, which is worse than a failed upload.
Size and checksum are measured here. The core persists both and the reclaim
path trusts them. ETag is not a sha256, and for a multipart object it is not
even an MD5.
get maps to a not-found error. This family of services says “no such
object” in several shapes — NoSuchKey, NotFound, a bare 404 — and a raw one
escaping makes a missing blob a 500 where the route means 404. Anything that
is not a missing key is rethrown: a permission error dressed as “not found”
would hide an outage behind a plausible answer.
Reaching Google Cloud Storage
GCS speaks the S3 XML API in interoperability mode, so this adapter reaches it with an HMAC key:
createS3StorageProvider({
bucket,
endpoint: 'https://storage.googleapis.com',
credentials: { accessKeyId: HMAC_ACCESS_ID, secretAccessKey: HMAC_SECRET }
});That is one fewer package to keep. Use the native GCS provider when HMAC keys are forbidden by policy or you need Workload Identity.