Features Apograph CMS on GitHub

Local filesystem

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

The default install — a directory on disk, and the one thing to change before production.

Documents 0.5.2 Updated Edit this page Report a problem

On this page

The default. A filesystem StorageProvider that streams blobs to a directory, depending only on Node built-ins and the port’s type, which is erased at runtime — it imports no framework.

npm install @apograph/media-provider-local
import { createLocalStorageProvider } from '@apograph/media-provider-local';

MediaServerPlugin({
    provider: createLocalStorageProvider(config.plugins.media.storage),
    config: config.plugins.media
});

Configuration

{ rootDir }, and nothing else.

VariableDefaultWhat it does
MEDIA_LOCAL_ROOT./.storage/mediaThe directory blobs are written under

The default root is ephemeral, and fails silently

./.storage/media 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.

Capabilities

{ directUrl: false, contentTypeMetadata: false, streamingPut: true }.

It identifies itself as local, which is the value written to every asset row’s storage_provider.

It cannot serve a signed URL

directUrl: false is the load-bearing one. A filesystem has nothing to sign, so MEDIA_DIRECT_SERVE=signed-url with this backend is a boot error rather than a request-time failure — which is the whole point of a capability being a claim the plugin checks.

Downloads stream through the app. On a local disk that is also the only thing that could work.

contentTypeMetadata: false because a filesystem has nowhere to put a content type. The core stores it in the database instead, which is where it was always read from.

How it stores

Keys are <workspaceId>/<assetId>/<sanitized-filename> under rootDir, so blobs stay workspace-partitioned and collision-free. Derivatives go under a reserved variants/ sub-namespace — so a thumbnail can never overwrite an original that happens to be called thumb.webp.

put streams the body through a metering pass that computes size and sha256, so a large file never buffers fully in memory.

Two invariants worth knowing before you touch it

put is all-or-nothing. Bytes go to a temporary file beside the target and are moved into place with one rename; any failure unlinks the temporary and prunes the directories the attempt created.

That is not defensive tidiness. The key exists only inside put’s return value, so a rejected write has handed nobody the name of what it created and the core’s reclaim loop can never see it. Simplified back to writing straight at the target, a failed upload leaves unreachable garbage on disk forever.

Every key is checked against the root. rootDir is resolved once at construction — the default is relative, so resolving per call would re-home the whole store the moment anything called chdir — and every key must land inside it.

The check is there for get and remove, whose keys come back from the database and are only as trustworthy as everything that can write that column.

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 alongside it, and make sure the two restore to a consistent point.