Features Apograph CMS on GitHub

Media plugin

Where uploaded bytes go, which backend puts them there, and how they come back out.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

Folders and assets for the Media Library, and the provider-agnostic storage seam underneath them. Bytes never live in the database: an asset row carries a storage_key and a storage_provider pointer, and the blob itself is held by whichever StorageProvider the composition root constructed.

Install

npm install @apograph/media-server @apograph/media-admin

Plus exactly one storage backend:

BackendPackageSigned URLs
Local diskmedia-provider-localno
S3-compatiblemedia-provider-s3yes
Azure Blobmedia-provider-azurewith a shared key
Google Cloudmedia-provider-gcswith a key or IAM
Vercel Blobmedia-provider-vercel-blobno
In-memorymedia-provider-memoryno — tests only

Register

// apps/server/src/plugins.ts
import { MediaServerPlugin } from '@apograph/media-server';
import { createS3StorageProvider } from '@apograph/media-provider-s3';

MediaServerPlugin({
    provider: createS3StorageProvider(config.plugins.media.storage),
    config: config.plugins.media
});
// apps/admin/src/plugins.ts
import { MediaPlugin } from '@apograph/media-admin';

MediaPlugin();

One provider per deployment, singular

This changed in 0.4.0. MediaServerPlugin takes a single constructed provider, not a providers array — there is no registry, no resolver and no defaultProvider. Bytes go to one place, so there is nothing to route between.

Swapping backend is swapping that one expression, and the type of media.storage moving with it.

Environment

Backend settings are the host’s, typed by whichever factory it imports. MediaPluginConfig names no backend at all.

VariableApplies toWhat it does
MEDIA_MAX_UPLOAD_BYTESallCaps one upload. Default 50 MB
MEDIA_DIRECT_SERVEallsigned-url redirects downloads to the backend. Default off
MEDIA_DIRECT_SERVE_TTL_SECONDSallLifetime of a signed URL. Default 300
MEDIA_LOCAL_ROOTlocalDirectory blobs are written to
MEDIA_S3_BUCKETS3Bucket name — required
MEDIA_S3_REGIONS3Default auto, which is what R2 expects. AWS needs its real region
MEDIA_S3_ENDPOINTS3Omit for AWS itself; set it for R2, MinIO, Spaces, B2
MEDIA_S3_FORCE_PATH_STYLES3true for MinIO and anything else that needs path-style URLs
MEDIA_S3_ACCESS_KEY_IDS3Omit on a host with an instance role or IRSA
MEDIA_S3_SECRET_ACCESS_KEYS3Omit with the above
MEDIA_AZURE_CONTAINERAzureContainer name — required
MEDIA_AZURE_CONNECTION_STRINGAzureRequired. Managed identity needs a hand-built client
MEDIA_GCS_BUCKETGCSBucket name — required
MEDIA_GCS_PROJECT_IDGCSOptional; Application Default Credentials otherwise
MEDIA_GCS_KEY_FILEGCSPath to a service-account key file
MEDIA_GCS_SIGN_WITH_IAMGCSSign URLs through IAM rather than a private key
BLOB_READ_WRITE_TOKENVercel BlobOnly needed off Vercel; the SDK reads it itself there

Omit S3 credentials rather than blanking them

Absent credentials mean “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 arrives as a 403 nobody can trace back to the configuration.

Full backend-by-backend setup, including the ephemeral-disk trap on the local provider, is on media storage.

Direct serve

directServe: 'signed-url' answers a download with a 302 to a short-lived URL the browser fetches from the backend itself, instead of streaming every byte through Nest. On an object store with no egress bill that is the entire payoff, and proxying every thumbnail throws it away.

Three rules hold, none of them optional:

  • It runs after authorization, never instead of it. Both raw routes resolve the asset and check membership first; the redirect only decides how already-permitted bytes travel.
  • Only a backend that can pin the response headers may do it. The signed URL carries Content-Disposition and Content-Type, because a redirect discards the app’s own — and an uploaded .html served inline from a bucket is stored XSS on the bucket’s origin.
  • The plugin refuses to boot on signed-url with a backend that cannot sign one, rather than quietly proxying while the operator believes otherwise.

Today S3 is the shipped provider that declares directUrl: true unconditionally; Azure and GCS compute it from how they were authenticated, and a deployment on managed identity or bare Application Default Credentials gets false.

Each backend’s page covers its own configuration in full, and writing a storage provider covers the port and its contract suite.

Downloads are treated as hostile bytes

media_asset.mime_type is whatever the uploader’s multipart part claimed — nothing sniffs the body. So every download carries nosniff and a sandboxing CSP, and Content-Disposition: inline is used only for an allowlist of types a browser renders without executing: raster images, audio, video, PDF and text/plain. HTML, XML, SVG and anything unknown are attachment.

SVG is excluded from the inline set deliberately. MediaKind files it as an image, but it is a scriptable document when navigated to.

Boot checks

StorageProviderCheck runs the provider’s verify() at boot and refuses to start when the asset table holds rows written by a different provider, naming it and its row count. A row naming another provider is bytes this process cannot reach, and failing loudly beats a Media Library full of broken thumbnails.

A missing media_asset table is not a failure — migrations are a separate step, so a fresh database still boots.