Beta . Ortha CMS is in public beta. Every plan is free until it ends.
Features Ortha CMS on GitHub

S3-compatible

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

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

Documents 0.13.1 Updated Edit this page Report a problem

On this page

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-s3

Configure

Example language
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
VariableDefaultNotes
MEDIA_S3_BUCKETrequiredMissing → boot error naming it
MEDIA_S3_REGIONautoWhat R2 expects. AWS needs its real region
MEDIA_S3_ENDPOINTunsetUnset = AWS. Set it for everything else
MEDIA_S3_FORCE_PATH_STYLEfalsetrue for MinIO and other path-style services
MEDIA_S3_ACCESS_KEY_IDunsetSet both keys or neither
MEDIA_S3_SECRET_ACCESS_KEYunset

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?.

  • keyPrefix puts every object under a fixed prefix, for a shared bucket.
  • client takes a pre-built S3Client.

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:9001

Upload 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.webp

With 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

  • put is 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’s ObjectNotFoundError → HTTP 404. Anything else is rethrown.