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-adminPlus exactly one storage backend:
| Backend | Package | Signed URLs |
|---|---|---|
| Local disk | media-provider-local | no |
| S3-compatible | media-provider-s3 | yes |
| Azure Blob | media-provider-azure | with a shared key |
| Google Cloud | media-provider-gcs | with a key or IAM |
| Vercel Blob | media-provider-vercel-blob | no |
| In-memory | media-provider-memory | no — 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.
| Variable | Applies to | What it does |
|---|---|---|
MEDIA_MAX_UPLOAD_BYTES | all | Caps one upload. Default 50 MB |
MEDIA_DIRECT_SERVE | all | signed-url redirects downloads to the backend. Default off |
MEDIA_DIRECT_SERVE_TTL_SECONDS | all | Lifetime of a signed URL. Default 300 |
MEDIA_LOCAL_ROOT | local | Directory blobs are written to |
MEDIA_S3_BUCKET | S3 | Bucket name — required |
MEDIA_S3_REGION | S3 | Default auto, which is what R2 expects. AWS needs its real region |
MEDIA_S3_ENDPOINT | S3 | Omit for AWS itself; set it for R2, MinIO, Spaces, B2 |
MEDIA_S3_FORCE_PATH_STYLE | S3 | true for MinIO and anything else that needs path-style URLs |
MEDIA_S3_ACCESS_KEY_ID | S3 | Omit on a host with an instance role or IRSA |
MEDIA_S3_SECRET_ACCESS_KEY | S3 | Omit with the above |
MEDIA_AZURE_CONTAINER | Azure | Container name — required |
MEDIA_AZURE_CONNECTION_STRING | Azure | Required. Managed identity needs a hand-built client |
MEDIA_GCS_BUCKET | GCS | Bucket name — required |
MEDIA_GCS_PROJECT_ID | GCS | Optional; Application Default Credentials otherwise |
MEDIA_GCS_KEY_FILE | GCS | Path to a service-account key file |
MEDIA_GCS_SIGN_WITH_IAM | GCS | Sign URLs through IAM rather than a private key |
BLOB_READ_WRITE_TOKEN | Vercel Blob | Only 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-DispositionandContent-Type, because a redirect discards the app’s own — and an uploaded.htmlserved inline from a bucket is stored XSS on the bucket’s origin. - The plugin refuses to boot on
signed-urlwith 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.