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

Media storage

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

Where uploaded bytes actually go, and the things to get right before production.

Documents 0.13.1 Updated Edit this page Report a problem

On this page
  • Asset metadata is in Postgres. The bytes are behind one storage provider.
  • The provider is constructed in plugins.ts and passed to the media plugin. There is no MEDIA_PROVIDER variable.
// 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

PackageFactorySigned URLsScaffolder id
@orthacms/media-provider-localcreateLocalStorageProvidernomedia-local
@orthacms/media-provider-s3createS3StorageProvideryesmedia-s3
@orthacms/media-provider-azurecreateAzureStorageProviderwith a shared keymedia-azure
@orthacms/media-provider-gcscreateGcsStorageProviderwith a key or IAM signingmedia-gcs
@orthacms/media-provider-vercel-blobcreateVercelBlobStorageProvidernomedia-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=52428800

The 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
});
Example language
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.ts and 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 itself

Every 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=300

A 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-Disposition and Content-Type on the signed URL may do it — otherwise an uploaded .html would be stored XSS on the bucket’s origin.
  • The plugin refuses to boot on signed-url with a backend that cannot sign.
  • Clients must follow redirects (curl -L). Do not cache the 302 — it carries Cache-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_asset table 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_BODY governs 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:

LayerEffect
X-Content-Type-Options: nosniffNo type upgrade by sniffing
Restrictive Content-Security-PolicyAn inline document loads no scripts, in an opaque origin
Content-Disposition: attachmentEverything 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.