Endpoint-first, not AWS-first: endpoint + forcePathStyle is the whole
difference between AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces,
Backblaze B2, Wasabi, Scaleway, Hetzner, Supabase Storage and Tigris.
id:s3- Capabilities:
{ directUrl: true, contentTypeMetadata: true, streamingPut: true }— the only shipped backend that always signs URLs.
Install
npm install @orthacms/media-provider-s3Configure
MEDIA_S3_BUCKET=acme-cms-media
MEDIA_S3_REGION=eu-west-1
# leave both keys out on EC2/ECS/EKS with an instance role or IRSA
MEDIA_S3_ACCESS_KEY_ID=AKIA...
MEDIA_S3_SECRET_ACCESS_KEY=...
MEDIA_DIRECT_SERVE=signed-url MEDIA_S3_BUCKET=acme-cms-media
MEDIA_S3_ENDPOINT=https://<account-id>.r2.cloudflarestorage.com
MEDIA_S3_REGION=auto
MEDIA_S3_ACCESS_KEY_ID=...
MEDIA_S3_SECRET_ACCESS_KEY=...
MEDIA_DIRECT_SERVE=signed-url MEDIA_S3_BUCKET=media
MEDIA_S3_ENDPOINT=http://localhost:9000
MEDIA_S3_FORCE_PATH_STYLE=true
MEDIA_S3_ACCESS_KEY_ID=minioadmin
MEDIA_S3_SECRET_ACCESS_KEY=minioadmin | Variable | Default | Notes |
|---|---|---|
MEDIA_S3_BUCKET | required | Missing → boot error naming it |
MEDIA_S3_REGION | auto | What R2 expects. AWS needs its real region |
MEDIA_S3_ENDPOINT | unset | Unset = AWS. Set it for everything else |
MEDIA_S3_FORCE_PATH_STYLE | false | true for MinIO and other path-style services |
MEDIA_S3_ACCESS_KEY_ID | unset | Set both keys or neither |
MEDIA_S3_SECRET_ACCESS_KEY | unset |
Omit credentials rather than blanking them
Absent keys mean “use the SDK’s provider chain” (instance role, IRSA, shared
config). The config below passes credentials only when both keys are set.
These variables are read by a generated app’s config/media-storage.ts (the
reference server ships with local disk):
import type { S3StorageConfig } from '@orthacms/media-provider-s3';
import { defined, readEnv, readFlag, requireEnv } from '@orthacms/utils-server';
export function mediaStorage(): S3StorageConfig {
const accessKeyId = readEnv('MEDIA_S3_ACCESS_KEY_ID');
const secretAccessKey = readEnv('MEDIA_S3_SECRET_ACCESS_KEY');
return defined({
bucket: requireEnv('MEDIA_S3_BUCKET'),
region: readEnv('MEDIA_S3_REGION') ?? 'auto',
endpoint: readEnv('MEDIA_S3_ENDPOINT'),
forcePathStyle: readFlag('MEDIA_S3_FORCE_PATH_STYLE', false),
credentials:
accessKeyId && secretAccessKey ? { accessKeyId, secretAccessKey } : undefined
});
}To switch the reference server to S3, copy this function into
apps/server/config/media.ts, change storage’s type to S3StorageConfig,
and swap the factory in plugins.ts.
All options
S3StorageConfig: bucket, region?, endpoint?, forcePathStyle?,
credentials? ({ accessKeyId, secretAccessKey, sessionToken? }),
keyPrefix?, client?.
keyPrefixputs every object under a fixed prefix, for a shared bucket.clienttakes a pre-builtS3Client.
Register
// apps/server/src/plugins.ts
import { createS3StorageProvider } from '@orthacms/media-provider-s3';
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});Verify
verify() sends one HeadBucket at boot. A wrong bucket, dead endpoint or
bad key refuses the start.
For MinIO locally:
docker run -d -p 9000:9000 -p 9001:9001 minio/minio server /data --console-address :9001
# create the bucket "media" in the console at http://localhost:9001Upload a file (curl example), then list it:
aws s3 ls "s3://$MEDIA_S3_BUCKET/" --recursive \
${MEDIA_S3_ENDPOINT:+--endpoint-url "$MEDIA_S3_ENDPOINT"}
# <workspaceId>/<assetId>/kingfisher.jpg
# <workspaceId>/<assetId>/variants/thumb.webpWith MEDIA_DIRECT_SERVE=signed-url, the download redirects to a presigned URL
that pins the response headers:
curl -si http://localhost:3000/api/v1/media/assets/<id>/raw \
-H 'Authorization: Bearer orthacms_…' | grep -i '^location'
# location: https://…/<assetId>/kingfisher.jpg?…response-content-disposition=inline…&response-content-type=image%2Fjpeg…&X-Amz-Signature=…GCS through this adapter
GCS speaks the S3 XML API with an HMAC key:
createS3StorageProvider({
bucket: 'acme-cms-media',
endpoint: 'https://storage.googleapis.com',
credentials: { accessKeyId: process.env.GCS_HMAC_ID!, secretAccessKey: process.env.GCS_HMAC_SECRET! }
});Use the native GCS provider when HMAC keys are forbidden or you need Workload Identity.
Behaviour worth knowing
putis all-or-nothing. A failed multipart upload is aborted, so no invisible billed parts are left.- Size and sha256 are measured by the adapter, not taken from
ETag. - Missing objects (
NoSuchKey,NotFound, bare 404) become the port’sObjectNotFoundError→ HTTP 404. Anything else is rethrown.