Storage and media
Where uploaded originals and generated image derivatives are stored (a local or shared filesystem, or an S3-compatible bucket), how media is served and cached, putting a CDN in front, upload limits, and keeping derivatives in check.
Every uploaded file and every image derivative the engine generates lives in one blob store, chosen by storage.driver. Everything else (the media library’s records, alt text, rights, focal points) is in the database. A backup is therefore the database and the store together.
Choose a driver
| Driver | Use when |
|---|---|
filesystem (default) | One machine, or several replicas that mount the same network filesystem |
s3 | Several replicas without a shared filesystem, or when you want the provider’s durability. Any S3-compatible service: Amazon S3, Cloudflare R2, MinIO, Backblaze B2, Wasabi, DigitalOcean Spaces and others |
At boot the engine proves the store works before it serves: a filesystem root must be writable, and a bucket must accept a write, a read back and a delete within 30 seconds. A store that fails stops the boot with an error naming the store and the step that failed, not the first upload.
Filesystem storage
storage:
driver: filesystem
filesystem:
root: ./storage | Key | Default | Meaning |
|---|---|---|
storage.filesystem.root | ./storage | The directory, relative to the project directory or absolute |
storage.filesystem.shared | false | Declares that root is one filesystem every replica mounts (NFS, Amazon EFS, a shared volume) |
The root must be writable by the engine’s user (uid 65532 in the container images). The engine creates what it needs below it:
storage/
originals/ab/ab12...e9.jpg # each upload, named by its SHA-256
derivatives/<asset id>/<hash>/1200.webp With server.replicas above 1, the filesystem driver is refused unless storage.filesystem.shared: true says every replica sees the same files: an upload that lands on one replica’s local disk is missing on the others. shared: true with one replica is a warning.
S3-compatible storage
storage:
driver: s3
s3:
endpoint: ${S3_ENDPOINT:-}
region: ${S3_REGION:-eu-west-2}
bucket: ${S3_BUCKET}
access_key_id: ${S3_ACCESS_KEY_ID}
secret_access_key: ${S3_SECRET_ACCESS_KEY} | Key | Default | Meaning |
|---|---|---|
storage.s3.endpoint | Empty | The service’s base URL, http or https with no path, such as https://<account>.r2.cloudflarestorage.com or http://minio:9000. Empty for Amazon S3, which is addressed by region |
storage.s3.region | eu-west-2 | Required. auto for Cloudflare R2; MinIO uses us-east-1 unless configured otherwise |
storage.s3.bucket | Empty | Required. The bucket must exist |
storage.s3.access_key_id | Empty | Required |
storage.s3.secret_access_key | Empty | Required. A secret: always ${VAR} |
storage.s3.path_style | false | Address the bucket as endpoint/bucket/key rather than bucket.endpoint/key. MinIO and some other services need true |
storage.s3.public_base_url | Empty | The https origin of a CDN in front of the engine’s /media/ path (below). Media addresses are written on it |
The credentials need to put, get, delete and list objects in the bucket. The bucket does not need to be public: the engine reads from it and serves media itself. A plain http endpoint is a warning in production unless it is on a private network the engine alone reaches.
How media is served
The engine serves every media file from its own /media/ path, on every site’s host:
| Address | What it is | Caching |
|---|---|---|
/media/<asset>/<hash>/<slug>-<width>.<ext> | A derivative: one image at one width and format, named by a signed hash of the asset, its version, its focal point and crop, and the transform | public, max-age=31536000, immutable. The address changes whenever any of those change |
/media/<asset>/o<version>/<name> | The original as uploaded: documents, audio, video, SVG, and an image’s own URL | immutable: a replaced file is a new version with a new address |
A derivative is generated on its first request and stored. While it cannot be generated, the request is answered with the original (which the browser scales), the failure is logged and counted, and the next request tries again. Byte ranges are answered from the store, so audio and video seek and a large PDF loads in parts. Originals are served so that they cannot run script on the site’s origin.
The hash is signed with media.signing_key, or a key derived from server.security.signing_key when that is unset, so nobody can ask the engine to generate a crop or size your templates never use. Changing either key changes every derivative address. Images covers transforms, widths and formats.
A CDN in front
Because every media address is immutable, any CDN can cache /media/ for a year. Two ways to put one in front:
| Approach | How |
|---|---|
| The whole site behind the CDN | Point the CDN at the engine for every path. Media is cached by its Cache-Control; pages are not cached by a shared cache unless you configure it |
| A media host of its own | Create a CDN origin that forwards /media/ to the engine, give it a host such as media.example.org, and set storage.s3.public_base_url: https://media.example.org. The engine then writes every media address on that origin. This works with the s3 driver only |
Upload limits
| Key | Default | Meaning |
|---|---|---|
media.max_upload_bytes | 50MB | The largest file an upload may carry. Sizes take B, KB, MB, GB (binary: 1MB is 1,048,576 bytes) |
media.max_pixels | 50000000 | The largest image, in pixels (width times height), the pipeline decodes. At least 1,000,000 |
media.accept | JPEG, PNG, WebP, AVIF, PDF, GPX | The media types an upload may have |
media.strip_exif_gps | true | Remove GPS coordinates from an image’s EXIF data on upload |
server.max_request_bytes | 25MB | Every other request body. A multipart upload may be up to media.max_upload_bytes plus 1MB |
A reverse proxy in front of the engine has its own body limit (nginx’s client_max_body_size is 1MB by default); raise it to at least media.max_upload_bytes. Caddy has no limit by default. HEIC uploads need the -vips image or a build linked against libvips.
Keeping derivatives in check
Derivatives accumulate as templates and transforms change. A scheduled task, media.prune_derivatives, runs daily and removes:
- every derivative of a deleted asset;
- derivatives not served or warmed for
media.derivatives.keep_unused(default90d); - once unused for a day, derivatives of an old version of an asset, or of a transform definition nothing produces any more.
Run it by hand, and see the store’s size:
engine-dmo media stats # the library's size, derivatives by transform and format, alt text coverage
engine-dmo media prune --dry-run # what would go, and the bytes it would free
engine-dmo media prune --older-than 30d After a deploy that changes transforms, generate the derivatives the published pages use before visitors ask for them:
engine-dmo media warm --dry-run # count what is missing
engine-dmo media warm # render every published page in process and generate what they reference
engine-dmo media warm --shard 0/4 # one quarter of the pages; run 1/4, 2/4 and 3/4 elsewhere in parallel Moving between drivers
There is no built-in copy between drivers. To move from filesystem to s3, copy the whole storage root into the bucket with its paths unchanged (for example with aws s3 sync ./storage s3://<bucket>/ or rclone copy), switch storage.driver, and restart. Stop uploads (or the engine) while you copy, so nothing lands in the old store after the copy starts.
Related
- Backups and restore: backing up the store with the database.
- Scaling: storage for several replicas.
- Media: the media library itself.
Database and migrations
What the engine needs from PostgreSQL, the connection pool settings, how engine migrations and schema apply run as deploy steps, what the engine refuses at boot, and how destructive schema changes are approved.
Backups and restore
What a complete Meridian backup contains (the database, the media store, the project and the signing key), scripts to take one and restore it, the order that keeps them consistent, and exporting one site as a standalone install.