Features Apograph CMS on GitHub

Media storage

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

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

Documents 0.5.2 Updated Edit this page Report a problem

On this page

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

PackageFactorySigned URLs
@apograph/media-provider-localcreateLocalStorageProviderno
@apograph/media-provider-s3createS3StorageProvideryes
@apograph/media-provider-azurecreateAzureStorageProviderno
@apograph/media-provider-gcscreateGcsStorageProviderno
@apograph/media-provider-vercel-blobcreateVercelBlobStorageProviderno

Local disk

The default install. config is { rootDir }.

MEDIA_LOCAL_ROOT=/var/lib/apograph/media
MEDIA_MAX_UPLOAD_BYTES=52428800

The 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.com

region 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=true

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

Three 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-Disposition and Content-Type, because a redirect discards the app’s own. Without that pinning, an uploaded .html served inline from the 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 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:

LayerEffect
X-Content-Type-Options: nosniffThe browser never upgrades a declared type by sniffing the body.
A restrictive Content-Security-PolicyAn inline-served document loads no scripts and no subresources, in an opaque origin.
Content-Disposition: attachmentEverything 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.