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
| Published | Not published |
|---|---|
Two Linux container images of engine-dmo, at ghcr.io/cherwell-studio/meridian | A standalone binary to download, for any platform |
linux/amd64 and linux/arm64 for the pure Go image; linux/amd64 for -vips | An engine-cms image |
The first-party extensions, inside the -vips image | A 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 | |
|---|---|---|
| Base | Distroless static (no C library) | Distroless cc (glibc only) with the libraries below |
| Platforms | linux/amd64, linux/arm64 | linux/amd64 |
engine-dmo version variant | pure-go | vips |
| Image encoding | JPEG and PNG | JPEG, PNG, WebP and AVIF, through libvips |
| HEIC uploads, ICC colour profiles | No | Yes |
| Member report as a tagged PDF | Built-in writer only | Through 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 build | Loads them, and carries the first-party ones in /app/extensions/ (the Vite integration is /app/extensions/vite.so) |
| Typical use | Small installs, a Node build stage that needs a static binary, arm64 hosts | Production |
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
| Tag | Points at |
|---|---|
0.8.0, 0.8.0-vips | That exact release. Pin this in production |
0.8, 0.8-vips | The newest patch release of 0.8 |
latest | The 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 property | Value |
|---|---|
| Binary and entrypoint | /app/engine-dmo |
| Default command | serve |
| Working directory | /app, which is also the default --project |
| User | nonroot (uid and gid 65532) |
| Exposed port | 8080 |
| Shell, package manager | None. Health checks and one-off commands call the binary directly |
| Writable paths the engine needs | The 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.yamllistens on${LISTEN_ADDR:-127.0.0.1:8080}, which a container cannot be reached on. SetLISTEN_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
| Constraint | Why |
|---|---|
Run with a read-only root filesystem and a tmpfs on /tmp | The engine writes only its storage root and temporary files |
Mount nothing over /app in your own image except the storage directory | The project files are part of the release |
| Do not run as root | The images are built to run as nonroot; nothing in them needs more |
| Pass the environment through your orchestrator | The 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).
Related
- Single-node deployment: the images under Docker Compose with Caddy, PostgreSQL and Meilisearch.
- Configuration and secrets: the environment the container needs.
- Monitoring: health checks, metrics and logs.
Configuration and secrets
How one project runs in development, staging and production through engine.environment and environment variables, how ${VAR} interpolation and the secret rule work, and every rule production enforces.
Single-node deployment
A complete production deployment on one virtual machine with Docker Compose (Caddy for TLS, the engine, PostgreSQL and Meilisearch), with every file to copy, the first start, backups and the same setup under systemd without containers.