- Asset metadata is in Postgres. The bytes are behind one storage provider.
- The provider is constructed in
plugins.tsand passed to the media plugin. There is noMEDIA_PROVIDERvariable.
// apps/server/src/plugins.ts
MediaServerPlugin({
provider: createLocalStorageProvider(config.plugins.media.storage),
config: config.plugins.media
});To switch backend, change three things: the factory in plugins.ts, config/media-storage.ts (the product repository: config/media.ts), and the storage type on AppMediaConfig. create-orthacms-app --media <id> generates all three.
The five backends
| Package | Factory | Signed URLs | Scaffolder id |
|---|---|---|---|
@orthacms/media-provider-local | createLocalStorageProvider | no | media-local |
@orthacms/media-provider-s3 | createS3StorageProvider | yes | media-s3 |
@orthacms/media-provider-azure | createAzureStorageProvider | with a shared key | media-azure |
@orthacms/media-provider-gcs | createGcsStorageProvider | with a key or IAM signing | media-gcs |
@orthacms/media-provider-vercel-blob | createVercelBlobStorageProvider | no | media-vercel-blob |
@orthacms/media-provider-memory is for tests only.
Local disk
MEDIA_LOCAL_ROOT=/var/lib/orthacms/media # a persistent volume
MEDIA_MAX_UPLOAD_BYTES=52428800The default root is ephemeral, and fails silently
./.storage/media lives on the container’s own disk. After a redeploy the library still lists every asset (the rows are in Postgres) and every download 404s. Mount a volume before uploading anything you care about.
S3-compatible
One adapter for AWS S3, Cloudflare R2, MinIO, DigitalOcean Spaces, Backblaze B2, Wasabi, Scaleway, Hetzner, Supabase Storage and Tigris — endpoint + forcePathStyle is the difference.
// apps/server/src/plugins.ts
import { createS3StorageProvider } from '@orthacms/media-provider-s3';
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});MEDIA_S3_BUCKET=my-orthacms-media
MEDIA_S3_REGION=eu-west-1
# no endpoint; no keys on an instance role / IRSA
MEDIA_DIRECT_SERVE=signed-url MEDIA_S3_BUCKET=my-orthacms-media
MEDIA_S3_REGION=auto
MEDIA_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.com
MEDIA_S3_ACCESS_KEY_ID=…
MEDIA_S3_SECRET_ACCESS_KEY=…
MEDIA_DIRECT_SERVE=signed-url MEDIA_S3_BUCKET=orthacms-media
MEDIA_S3_REGION=auto
MEDIA_S3_ENDPOINT=http://localhost:9000
MEDIA_S3_FORCE_PATH_STYLE=true
MEDIA_S3_ACCESS_KEY_ID=minioadmin
MEDIA_S3_SECRET_ACCESS_KEY=minioadmin A local MinIO for development (create the bucket in the console on :9001):
# docker-compose.override.yml
services:
minio:
image: minio/minio
command: server /data --console-address ":9001"
ports:
- '9000:9000'
- '9001:9001'
volumes:
- minio-data:/data
volumes:
minio-data:Set both keys, or neither
Absent keys → the SDK’s own chain (instance role, IRSA, shared config). Blank keys would shadow it with credentials that cannot sign — a 403 nobody traces back to config.
Azure Blob Storage
MEDIA_AZURE_CONTAINER=orthacms-media
MEDIA_AZURE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=…;AccountKey=…;EndpointSuffix=core.windows.net- Both are required.
- Signed URLs need the account key (a connection string with
AccountKey). A SAS connection string or managed identity cannot sign — downloads are then proxied. - Managed identity: build the container client yourself in
plugins.tsand pass it to the factory.
Google Cloud Storage
MEDIA_GCS_BUCKET=orthacms-media
MEDIA_GCS_PROJECT_ID=my-project # optional
MEDIA_GCS_KEY_FILE=/secrets/sa.json # optional; omit on GKE / Cloud Run (ADC)
MEDIA_GCS_SIGN_WITH_IAM=true # sign via IAM; needs iam.serviceAccounts.signBlob- Signing needs a key file, inline credentials, or
MEDIA_GCS_SIGN_WITH_IAM=true. Bare ADC cannot sign — downloads are proxied. - With an HMAC key, the S3 adapter reaches GCS too.
Vercel Blob
BLOB_READ_WRITE_TOKEN=… # only when running off Vercel; on Vercel the SDK reads it itselfEvery blob gets a permanent public URL
Vercel Blob has no private mode. Anyone with a URL can fetch the asset, whatever Ortha’s permissions say. Not for confidential media.
Direct serve
MEDIA_DIRECT_SERVE=signed-url
MEDIA_DIRECT_SERVE_TTL_SECONDS=300A download answers 302 to a short-lived backend URL instead of streaming through the app.
curl -L -o hero.jpg https://cms.example.com/api/v1/media/assets/<asset-id>/raw \
-H 'Authorization: Bearer orthacms_…'- Authorization runs first. A non-member still gets a 404.
- Only backends that pin
Content-DispositionandContent-Typeon the signed URL may do it — otherwise an uploaded.htmlwould be stored XSS on the bucket’s origin. - The plugin refuses to boot on
signed-urlwith a backend that cannot sign. - Clients must follow redirects (
curl -L). Do not cache the 302 — it carriesCache-Control: private, no-store.
S3 always can; Azure and GCS when given something to sign with; local disk and Vercel Blob never.
Boot checks
- Every asset row records the provider that wrote it.
- At boot the plugin runs the provider’s
verify()and refuses to start if rows were written by a different provider — naming it and the row count. - A missing
media_assettable is fine (migrations are a separate step).
Upload limits
MEDIA_MAX_UPLOAD_BYTES(default 50 MB) caps one upload, on both the session and the token routes.MAX_REQUEST_BODYgoverns JSON bodies, not uploads.- Your reverse proxy has its own limit — nginx:
client_max_body_size 50m;.
Derivatives
Raster images get two WebP derivatives on upload: thumb (≤ 320 px) and preview (≤ 1280 px), never upscaled. No on-the-fly resizing.
Downloads are hardened
MIME types are whatever the uploader claimed — nothing sniffs the bytes — so both download routes apply:
| Layer | Effect |
|---|---|
X-Content-Type-Options: nosniff | No type upgrade by sniffing |
Restrictive Content-Security-Policy | An inline document loads no scripts, in an opaque origin |
Content-Disposition: attachment | Everything outside a small allowlist downloads |
- Inline allowlist: PDF, plain text, common raster images, audio, video.
- SVG is excluded — it is a scriptable document when navigated to.
<img>and<video>ignore disposition, so previews keep working.
Asset URLs are authenticated
/api/v1/media/assets/:id/raw needs the bearer token; the admin route needs a session. There is no public asset URL. For a public site, proxy through your server or copy assets to a CDN at build time — see the media API.
Backups
Back up the media root (or rely on the object store’s durability) together with the database, restorable to the same point.
No cleanup of unreferenced assets
Deleting an entry does not delete its assets, and nothing finds orphaned ones. The library grows until someone curates it.