Media metadata lives in the database. The bytes live behind a storage provider, constructed at the composition root and passed to the media plugin as one object.
// apps/server/src/plugins.ts
MediaServerPlugin({
provider: createLocalStorageProvider(config.plugins.media.storage),
config: config.plugins.media
});One provider per deployment, singular
This changed in 0.4.0. The plugin 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 in apograph.config.ts moving with it.
Backend settings are the host’s, typed by whichever factory it imports.
MediaPluginConfig carries maxUploadBytes, the direct-serve settings, and
nothing about a backend at all.
The five backends
| Package | Factory | Signed URLs |
|---|---|---|
@apograph/media-provider-local | createLocalStorageProvider | no |
@apograph/media-provider-s3 | createS3StorageProvider | yes |
@apograph/media-provider-azure | createAzureStorageProvider | no |
@apograph/media-provider-gcs | createGcsStorageProvider | no |
@apograph/media-provider-vercel-blob | createVercelBlobStorageProvider | no |
Local disk
The default install. config is { rootDir }.
MEDIA_LOCAL_ROOT=/var/lib/apograph/media
MEDIA_MAX_UPLOAD_BYTES=52428800The default root is ephemeral, and fails silently
MEDIA_LOCAL_ROOT defaults to ./.storage/media, which is git-ignored and
sits on the container’s own filesystem.
A fresh container starts with an empty directory. The database still holds every asset row, so the library lists them and every entry still references them — and every download 404s. Nothing warns you, because from the database’s point of view nothing is wrong.
Point it at a persistent volume before you upload anything you care about.
S3-compatible
Written endpoint-first rather than AWS-first. endpoint plus forcePathStyle
is the whole difference between AWS S3, Cloudflare R2, MinIO, DigitalOcean
Spaces, Backblaze B2, Wasabi, Scaleway, Hetzner, Supabase Storage and Tigris —
one adapter reaches all of them.
import { createS3StorageProvider } from '@apograph/media-provider-s3';
MediaServerPlugin({
provider: createS3StorageProvider(config.plugins.media.storage),
config: config.plugins.media
});MEDIA_S3_BUCKET=my-apograph-media
MEDIA_S3_REGION=auto
MEDIA_S3_ENDPOINT=https://<account>.r2.cloudflarestorage.comregion defaults to auto, which is what R2 expects. AWS needs its real
region; most other services ignore the value, but the request signer still
needs one.
Omit 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 surfaces as a 403 nobody can trace back to the configuration.
Set MEDIA_S3_ACCESS_KEY_ID and MEDIA_S3_SECRET_ACCESS_KEY together, or set
neither.
Azure Blob Storage
The one store with no S3 compatibility, which is why it gets its own adapter.
config is { container, connectionString } — or an account name and key.
MEDIA_AZURE_CONTAINER=apograph-media
MEDIA_AZURE_CONNECTION_STRING=DefaultEndpointsProtocol=https;AccountName=…Managed identity needs a hand-built client passed to the factory rather than a connection string.
Google Cloud Storage
Native Google authentication. With no key file and no inline credentials the client uses Application Default Credentials, which is what a GKE or Cloud Run deployment wants.
MEDIA_GCS_BUCKET=apograph-media
MEDIA_GCS_PROJECT_ID=my-project
MEDIA_GCS_SIGN_WITH_IAM=trueIf an HMAC key is acceptable, the S3-compatible adapter reaches GCS over its XML API too — one package fewer.
Vercel Blob
The smallest setup on Vercel, where the SDK reads BLOB_READ_WRITE_TOKEN
itself and the config can be empty.
Every blob gets a permanent public URL
Vercel Blob has no private mode. Anything uploaded is world-readable to anyone holding its URL, whatever Apograph’s own permissions say.
Do not choose it for confidential media.
Direct serve
MEDIA_DIRECT_SERVE=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 the app. On an object store with no egress bill
that is the entire payoff, and proxying every thumbnail throws it away.
MEDIA_DIRECT_SERVE=signed-url
MEDIA_DIRECT_SERVE_TTL_SECONDS=300Three rules hold:
- It runs after authorization, never instead of it. Both raw routes resolve the asset and check membership — or the token’s workspace — first. The redirect only decides how already-permitted bytes travel, and a non-member still gets a 404.
- 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. Without that pinning, an uploaded.htmlserved inline from the 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 you believe otherwise.
Today S3 is the shipped provider that declares the capability. off is the
default, and it streams every download through the app.
Boot checks
Every asset row records the provider that wrote it. At boot, the plugin runs
the provider’s verify() and refuses to start when the 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 of broken thumbnails. A missing media_asset
table is not a failure: migrations are a separate step, so a fresh database
still boots.
Upload limits
MEDIA_MAX_UPLOAD_BYTES defaults to 50 MB and caps a single upload. It is
enforced by the same setting on both the session route and the token route.
The admin’s upload dialog deliberately quotes no number, because no route reports the cap to it — a hard-coded hint was a number nothing enforced, and a file over the real cap staged happily and then failed after transferring in full.
If you raise it, also check MAX_REQUEST_BODY — that governs JSON bodies
rather than multipart uploads, so the two are independent, but a proxy in front
of Apograph usually has its own body limit that is easy to forget.
Derivatives
Raster images get thumbnail and preview derivatives generated on upload, and the library grid uses them rather than loading originals.
There is no on-the-fly resizing and no image transformation API.
Downloads are hardened
Worth knowing about, because it explains why some files download instead of opening.
An asset’s MIME type is whatever the uploader’s multipart part claimed —
nothing sniffs the bytes — so the download routes treat it as hostile. Served
naively, an uploaded .html or a scripted .svg would be stored XSS on the
app’s own origin, since the API and the admin share a host.
Three layers are applied on both download routes:
| Layer | Effect |
|---|---|
X-Content-Type-Options: nosniff | The browser never upgrades a declared type by sniffing the body. |
A restrictive Content-Security-Policy | An inline-served document loads no scripts and no subresources, in an opaque origin. |
Content-Disposition: attachment | Everything outside a small allowlist downloads rather than rendering. |
The inline allowlist is PDF, plain text, and the common raster image types, plus audio and video. SVG is deliberately excluded: it is an image, but it is also a scriptable document when navigated to directly.
Disposition only affects navigations — <img src> and <video> ignore it
— so the library’s tiles and previews keep working while a direct hit on a
booby-trapped file downloads instead of executing.
Backups
Blobs are not in the database. A database backup on its own restores a library of rows pointing at bytes that are gone.
Back up the media root, or rely on your object store’s own durability, and make sure the two are restorable to a consistent point.
Asset URLs are authenticated
/api/v1/media/assets/:id/raw requires the bearer token, and the admin’s
equivalent requires a session. There is no unauthenticated asset URL: direct
serve issues a signed URL after the same authorization check, and its
lifetime is MEDIA_DIRECT_SERVE_TTL_SECONDS.
For a public site, proxy the bytes through your own server or copy assets to a CDN at build time. See the media API.
No cleanup of unreferenced assets
Deleting an entry does not delete the assets it referenced, and nothing identifies assets no entry points at. The library grows until somebody curates it by hand.