Meridian handbook
Deploying

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

DriverUse when
filesystem (default)One machine, or several replicas that mount the same network filesystem
s3Several 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
KeyDefaultMeaning
storage.filesystem.root./storageThe directory, relative to the project directory or absolute
storage.filesystem.sharedfalseDeclares 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}
KeyDefaultMeaning
storage.s3.endpointEmptyThe 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.regioneu-west-2Required. auto for Cloudflare R2; MinIO uses us-east-1 unless configured otherwise
storage.s3.bucketEmptyRequired. The bucket must exist
storage.s3.access_key_idEmptyRequired
storage.s3.secret_access_keyEmptyRequired. A secret: always ${VAR}
storage.s3.path_stylefalseAddress the bucket as endpoint/bucket/key rather than bucket.endpoint/key. MinIO and some other services need true
storage.s3.public_base_urlEmptyThe 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:

AddressWhat it isCaching
/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 transformpublic, 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 URLimmutable: 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:

ApproachHow
The whole site behind the CDNPoint 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 ownCreate 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

KeyDefaultMeaning
media.max_upload_bytes50MBThe largest file an upload may carry. Sizes take B, KB, MB, GB (binary: 1MB is 1,048,576 bytes)
media.max_pixels50000000The largest image, in pixels (width times height), the pipeline decodes. At least 1,000,000
media.acceptJPEG, PNG, WebP, AVIF, PDF, GPXThe media types an upload may have
media.strip_exif_gpstrueRemove GPS coordinates from an image’s EXIF data on upload
server.max_request_bytes25MBEvery 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 (default 90d);
  • 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.