Features Apograph CMS on GitHub

S3-compatible

Media plugin @apograph/media-server@apograph/media-admin

AWS, R2, MinIO, Spaces, B2, Wasabi — one adapter reaches all of them.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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-s3
import { 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? }

VariableDefaultWhat it does
MEDIA_S3_BUCKETRequired
MEDIA_S3_REGIONautoWhat R2 expects. AWS needs its real region
MEDIA_S3_ENDPOINTOmit for AWS itself; set it for everything else
MEDIA_S3_FORCE_PATH_STYLEfalsetrue for MinIO and anything needing path-style URLs
MEDIA_S3_ACCESS_KEY_IDOmit on a host with an instance role
MEDIA_S3_SECRET_ACCESS_KEYOmit 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.