Meridian handbook
Deploying

Container images

The two published engine images (pure Go and -vips), their tags, what each contains, how to run one with your project mounted or baked into an image of your own, and the runtime constraints (non-root, no shell, read-only root).

Each release is published as two Linux container images of engine-dmo. Run the -vips one in production unless you have a reason not to: it encodes WebP and AVIF, reads HEIC, prints member reports as PDF and loads extensions.

docker run --rm ghcr.io/cherwell-studio/meridian:0.8-vips version

What a release publishes

PublishedNot published
Two Linux container images of engine-dmo, at ghcr.io/cherwell-studio/meridianA standalone binary to download, for any platform
linux/amd64 and linux/arm64 for the pure Go image; linux/amd64 for -vipsAn engine-cms image
The first-party extensions, inside the -vips imageA macOS or Windows build
The example projects, the meridian-extend module source and the migration toolkit

For a Linux host that runs the binary without containers (the systemd setup), copy it out of the pure Go image, which is statically linked:

docker create --name meridian-copy --platform linux/amd64 ghcr.io/cherwell-studio/meridian:0.8.0
docker cp meridian-copy:/app/engine-dmo ./engine-dmo
docker rm meridian-copy
./engine-dmo version

The -vips binary needs the libraries its image carries and does not run outside it. On a macOS or Windows workstation, run the engine from the image with your project mounted (see below), for example docker run --rm -v "$PWD":/srv/project ghcr.io/cherwell-studio/meridian:0.8 check --no-db --project /srv/project.

The two images

meridian:<version>meridian:<version>-vips
BaseDistroless static (no C library)Distroless cc (glibc only) with the libraries below
Platformslinux/amd64, linux/arm64linux/amd64
engine-dmo version variantpure-govips
Image encodingJPEG and PNGJPEG, PNG, WebP and AVIF, through libvips
HEIC uploads, ICC colour profilesNoYes
Member report as a tagged PDFBuilt-in writer onlyThrough a bundled headless Chromium, with Noto fonts for Latin, Arabic, Thai, CJK and the other core scripts
Extensions (extensions: in engine.yaml)Cannot load any: Go plugins need a cgo buildLoads them, and carries the first-party ones in /app/extensions/ (the Vite integration is /app/extensions/vite.so)
Typical useSmall installs, a Node build stage that needs a static binary, arm64 hostsProduction

Both images contain engine-dmo only. A project written for engine-cms runs on engine-dmo unchanged, so the same images serve CMS projects.

Tags

TagPoints at
0.8.0, 0.8.0-vipsThat exact release. Pin this in production
0.8, 0.8-vipsThe newest patch release of 0.8
latestThe newest pure Go release that is not a release candidate. There is no latest-vips

The registry is ghcr.io/cherwell-studio/meridian. Check what you pulled with docker run --rm <image> version, which prints the version, bundle, modules, variant and what the image pipeline can encode. In an image build, version --require fails the build unless the image has what you depend on:

RUN ["/app/engine-dmo", "version", "--require", "webp,avif,heic,icc,pdf,extension:vite"]

What is inside

Path or propertyValue
Binary and entrypoint/app/engine-dmo
Default commandserve
Working directory/app, which is also the default --project
Usernonroot (uid and gid 65532)
Exposed port8080
Shell, package managerNone. Health checks and one-off commands call the binary directly
Writable paths the engine needsThe storage root (storage.filesystem.root) and /tmp. The -vips image points Chromium’s and fontconfig’s caches at /tmp (XDG_CACHE_HOME=/tmp/.cache, XDG_CONFIG_HOME=/tmp/.config)

Run it with your project mounted

The quickest route: mount the project read only, give the storage directory a volume, and pass the environment.

docker run -d --name meridian \
  --read-only --tmpfs /tmp \
  -p 8080:8080 \
  -v "$PWD":/srv/project:ro \
  -v meridian-storage:/srv/project/storage \
  -e ENVIRONMENT=production \
  -e LISTEN_ADDR=0.0.0.0:8080 \
  -e BASE_URL=https://www.example.org \
  -e DATABASE_URL='postgres://meridian:secret@db:5432/meridian?sslmode=require' \
  -e SIGNING_KEY \
  ghcr.io/cherwell-studio/meridian:0.8.0-vips \
  serve --project /srv/project

Points to get right:

  • Listen on every interface. A new project’s engine.yaml listens on ${LISTEN_ADDR:-127.0.0.1:8080}, which a container cannot be reached on. Set LISTEN_ADDR=0.0.0.0:8080.
  • The storage directory must exist in the project (mkdir storage) when the project is mounted read only, so the volume has a mount point.
  • The volume must be writable by uid 65532. A new named volume mounted over a path that does not exist in the image is owned by root. Change its owner once (docker run --rm -v meridian-storage:/s busybox chown -R 65532:65532 /s), or bake the directory into your own image with the right owner (below). The engine checks at boot that it can write to the storage root and refuses to start if it cannot.
  • Migrations and the schema are separate steps. Run them with the same image and environment before serve:
docker run --rm --env-file prod.env -v "$PWD":/srv/project:ro \
  ghcr.io/cherwell-studio/meridian:0.8.0-vips migrate up --project /srv/project
docker run --rm --env-file prod.env -v "$PWD":/srv/project:ro \
  ghcr.io/cherwell-studio/meridian:0.8.0-vips schema apply --project /srv/project

Build an image of your own

For anything beyond one machine, build an image that is the engine image plus your project, so a deploy is one tag and a rollback is the previous one. The project’s files go in /app, the image’s working directory and default project:

# syntax=docker/dockerfile:1
ARG MERIDIAN_IMAGE=ghcr.io/cherwell-studio/meridian:0.8.0-vips
FROM ${MERIDIAN_IMAGE}
COPY config/ /app/config/
COPY templates/ /app/templates/
COPY public/ /app/public/

# The storage mount point, owned by the non-root user, so a new named
# volume mounted here starts writable. The image has no shell to mkdir.
COPY --chown=nonroot:nonroot <<EOF /app/storage/README
Uploaded media. Mount a volume here; never bake content into the image.
EOF

# Refuse to build a project that does not check: configuration, schema,
# routes, templates and the built frontend, with no database or secrets.
RUN ["/app/engine-dmo", "check", "--no-db", "--project", "/app"]
docker build -t registry.example.org/visit-example:2026-11-02 .

The image then runs with no arguments (serve in /app), and the one-off steps are migrate up and schema apply with no --project.

With a frontend build

A project whose CSS and JavaScript are built with Vite builds them in a Node stage. The Vite plugin asks the engine for the project’s asset manifest, so copy the static binary from the pure Go image of the same release into that stage and name it with MERIDIAN_ENGINE; the pure Go binary runs on any Linux base, the -vips one does not:

# syntax=docker/dockerfile:1
ARG MERIDIAN_IMAGE=ghcr.io/cherwell-studio/meridian:0.8.0-vips
ARG MERIDIAN_STATIC_IMAGE=ghcr.io/cherwell-studio/meridian:0.8.0

FROM ${MERIDIAN_STATIC_IMAGE} AS engine

FROM node:24-trixie-slim AS assets
COPY --from=engine /app/engine-dmo /usr/local/bin/engine-dmo
WORKDIR /src
COPY package.json package-lock.json ./
RUN npm ci --no-audit --no-fund
COPY vite.config.js ./
COPY config/ config/
COPY templates/ templates/
COPY src/ src/
ENV MERIDIAN_ENGINE=/usr/local/bin/engine-dmo
RUN npm run build

FROM ${MERIDIAN_IMAGE}
COPY config/ /app/config/
COPY templates/ /app/templates/
COPY --from=assets /src/public/ /app/public/
COPY --chown=nonroot:nonroot <<EOF /app/storage/README
Uploaded media. Mount a volume here.
EOF
RUN ["/app/engine-dmo", "check", "--no-db", "--project", "/app"]

Use the same release for both build arguments. The frontend build covers the Vite configuration itself.

Running constraints

ConstraintWhy
Run with a read-only root filesystem and a tmpfs on /tmpThe engine writes only its storage root and temporary files
Mount nothing over /app in your own image except the storage directoryThe project files are part of the release
Do not run as rootThe images are built to run as nonroot; nothing in them needs more
Pass the environment through your orchestratorThe engine does not read .env files
Health checks call the binary: ["CMD", "/app/engine-dmo", "health", "--readiness"]There is no shell and no curl in the image. Add --project when the project is not in /app

engine-dmo health reads server.listen from the project’s configuration and requests /health there, so it needs the same environment as the server (at least LISTEN_ADDR when the configuration interpolates it, and every variable without a default).