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-localimport { createLocalStorageProvider } from '@apograph/media-provider-local';
MediaServerPlugin({
provider: createLocalStorageProvider(config.plugins.media.storage),
config: config.plugins.media
});Configuration
{ rootDir }, and nothing else.
| Variable | Default | What it does |
|---|---|---|
MEDIA_LOCAL_ROOT | ./.storage/media | The 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.