Meridian handbook
Deploying

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.

The single-node deployment runs everything on one virtual machine: Caddy terminating TLS with an automatic certificate, the engine, PostgreSQL and Meilisearch, under Docker Compose. It suits most destination and content sites, and every file it needs is on this page.

Only Caddy is reachable from outside; everything else talks on the Compose network:

flowchart LR
  people["Visitors and staff"] -->|"HTTPS 443"| caddy
  subgraph vm["One virtual machine"]
    caddy["caddy<br/>TLS, automatic certificate"] -->|"HTTP 8080"| engine["engine<br/>engine-dmo serve"]
    engine --> db[("db<br/>PostgreSQL 16")]
    engine --> search["search<br/>Meilisearch"]
    engine --> media[("storage volume<br/>uploaded media")]
    project["./project<br/>read only"] --> engine
  end
  engine -->|"SMTP 587"| relay["Your mail relay"]
  db -.->|"backup.sh, daily"| offsite["Backups off the machine"]

A machine with 2 vCPUs, 4 GB of memory and an SSD is a reasonable start. Size the disk for your media library, the database and its backups, and keep backups off the machine.

The directory

meridian/
  compose.yaml
  Caddyfile
  .env              # secrets, never committed
  backup.sh
  restore.sh
  project/          # your project: config/, templates/, public/
    storage/        # empty; the media volume mounts here

Put your project in ./project and create its empty storage directory (mkdir project/storage), which the media volume mounts over.

Add the deployment settings below to project/config/engine.yaml.

Write compose.yaml, Caddyfile and .env from this page, and generate every secret with openssl rand -base64 48.

Point your domain’s DNS at the machine and open ports 80 and 443.

Start it with docker compose up -d. Migrations and the schema are applied before the engine starts, and Caddy waits for the engine to report ready.

The project’s engine.yaml

The settings a single-node deployment relies on. The rest of your engine.yaml stays as it is.

engine:
  version: "1.x"
  environment: ${ENVIRONMENT:-development}

server:
  listen: ${LISTEN_ADDR:-127.0.0.1:8080}
  base_url: ${BASE_URL:-http://localhost:8080}
  # Caddy connects from the Compose network's fixed range.
  trusted_proxies: ["172.30.0.0/24"]
  security:
    signing_key: ${SIGNING_KEY}

database:
  url: ${DATABASE_URL}

logging:
  format: json

storage:
  driver: filesystem
  filesystem:
    root: ./storage

media:
  signing_key: ${MEDIA_SIGNING_KEY}

search:
  driver: meilisearch
  meilisearch:
    url: ${MEILISEARCH_URL}
    api_key: ${MEILISEARCH_API_KEY}

mail:
  driver: smtp
  from: "Visit Example <hello@example.org>"
  smtp:
    host: ${SMTP_HOST}
    port: 587
    username: ${SMTP_USERNAME}
    password: ${SMTP_PASSWORD}
    tls: starttls

telemetry:
  metrics:
    # Reachable on the Compose network for Prometheus; never publish this port.
    bind: 0.0.0.0:9090

Check it as production will read it before the first start:

ENVIRONMENT=production engine-dmo check --no-db --project project

compose.yaml

name: meridian

x-engine: &engine
  image: ${MERIDIAN_IMAGE}
  env_file: .env
  environment:
    ENVIRONMENT: production
    LISTEN_ADDR: 0.0.0.0:8080
    DATABASE_URL: postgres://meridian:${POSTGRES_PASSWORD}@db:5432/meridian?sslmode=disable
    MEILISEARCH_URL: http://search:7700
  read_only: true
  tmpfs: [/tmp]
  volumes:
    - ${PROJECT_DIR:-./project}:/srv/project:ro
    - storage:/srv/project/storage
  networks: [meridian]

services:
  caddy:
    image: caddy:2
    restart: unless-stopped
    ports: ["${HTTP_PORT:-80}:80", "${HTTPS_PORT:-443}:443"]
    environment:
      SITE_ADDRESS: ${SITE_ADDRESS}
    volumes:
      - ./Caddyfile:/etc/caddy/Caddyfile:ro
      - caddy-data:/data
      - caddy-config:/config
    depends_on:
      engine: { condition: service_healthy }
    networks: [meridian]

  # A new volume is owned by root; the engine runs as uid 65532.
  storage-init:
    image: busybox:1.37
    command: ["chown", "-R", "65532:65532", "/storage"]
    volumes: [storage:/storage]

  # Migrations and the schema are explicit steps before the engine starts.
  # Each runs once and exits; the image has no shell, so they are two.
  migrate:
    <<: *engine
    command: ["migrate", "up", "--project", "/srv/project"]
    restart: "no"
    depends_on:
      db: { condition: service_healthy }
      storage-init: { condition: service_completed_successfully }

  schema:
    <<: *engine
    command: ["schema", "apply", "--project", "/srv/project"]
    restart: "no"
    depends_on:
      migrate: { condition: service_completed_successfully }

  engine:
    <<: *engine
    command: ["serve", "--project", "/srv/project"]
    restart: unless-stopped
    healthcheck:
      test: ["CMD", "/app/engine-dmo", "health", "--project", "/srv/project", "--readiness"]
      interval: 5s
      timeout: 3s
      retries: 30
    depends_on:
      schema: { condition: service_completed_successfully }
      search: { condition: service_healthy }

  db:
    image: postgres:16
    restart: unless-stopped
    environment:
      POSTGRES_DB: meridian
      POSTGRES_USER: meridian
      POSTGRES_PASSWORD: ${POSTGRES_PASSWORD}
      POSTGRES_INITDB_ARGS: --encoding=UTF8 --locale=C.UTF-8
    volumes: [pgdata:/var/lib/postgresql/data]
    healthcheck:
      test: ["CMD-SHELL", "pg_isready -U meridian"]
      interval: 2s
      timeout: 2s
      retries: 30
    networks: [meridian]

  search:
    image: getmeili/meilisearch:v1.15
    restart: unless-stopped
    environment:
      MEILI_MASTER_KEY: ${MEILISEARCH_API_KEY}
      MEILI_ENV: production
      MEILI_NO_ANALYTICS: "true"
    volumes: [meili:/meili_data]
    healthcheck:
      test: ["CMD-SHELL", "curl -fs http://localhost:7700/health"]
      interval: 2s
      timeout: 2s
      retries: 30
    networks: [meridian]

# A fixed subnet, so server.trusted_proxies can name Caddy's range.
networks:
  meridian:
    ipam:
      config: [{ subnet: 172.30.0.0/24 }]

volumes:
  pgdata: {}
  meili: {}
  storage: {}
  caddy-data: {}
  caddy-config: {}

What it does:

ServiceRole
caddyTerminates TLS for SITE_ADDRESS with an automatic certificate and forwards everything to the engine. It starts once the engine is healthy
storage-initGives the media volume to the engine’s user, then exits
migrateengine-dmo migrate up, once, then exits
schemaengine-dmo schema apply, once, after migrate, then exits. A destructive plan stops here (see Database and migrations)
engineengine-dmo serve. Healthy once health --readiness passes
dbPostgreSQL 16, UTF8, with its data in the pgdata volume
searchMeilisearch, keyed with MEILISEARCH_API_KEY, its index in the meili volume

Compose starts the services in this order on every docker compose up -d, and a step that fails stops everything after it:

flowchart LR
  init["storage-init<br/>chown, exits"] --> migrate
  dbok["db healthy"] --> migrate["migrate<br/>migrate up, exits 0"]
  migrate --> schema["schema<br/>schema apply, exits 0"]
  schema --> engine["engine<br/>serve"]
  searchok["search healthy"] --> engine
  engine -->|"health --readiness passes"| caddy["caddy<br/>takes traffic"]

Only Caddy publishes ports. The engine’s port 8080, its metrics port 9090, PostgreSQL and Meilisearch are reachable on the Compose network alone.

Caddyfile

{$SITE_ADDRESS} {
	encode zstd gzip
	reverse_proxy engine:8080
}

Caddy sets X-Forwarded-For and X-Forwarded-Proto. The engine believes them only from server.trusted_proxies, which is why the network has a fixed subnet. To serve several sites from one install, list each domain: www.example.org, www.example.com { ... } with SITE_ADDRESS replaced by the list, and each domain in its site’s site.yaml.

.env

# Copy beside compose.yaml and fill in. Never commit this file.
# Generate each secret with: openssl rand -base64 48

# The address Caddy serves: a domain for an automatic certificate,
# or http://localhost:8088 to try it locally.
SITE_ADDRESS=www.example.org
# The public URL the engine builds links with.
BASE_URL=https://www.example.org
PROJECT_DIR=./project
MERIDIAN_IMAGE=ghcr.io/cherwell-studio/meridian:0.8.0-vips

POSTGRES_PASSWORD=
SIGNING_KEY=
MEDIA_SIGNING_KEY=
MEILISEARCH_API_KEY=

SMTP_HOST=
SMTP_USERNAME=
SMTP_PASSWORD=

env_file: .env passes every line to the engine containers; POSTGRES_PASSWORD and MEILISEARCH_API_KEY also configure the database and search containers through Compose interpolation.

The first start

docker compose up -d
docker compose ps                          # migrate and schema exited 0; engine healthy
docker compose logs -f engine              # JSON log lines on standard error

Then create the first staff user. The command prints a single-use enrolment link to open in a browser:

docker compose exec engine /app/engine-dmo user create --project /srv/project \
  --email you@example.org --name "Your Name"

The first user joins the administrators group unless you name another with --group. If the database already holds content from another environment, restore it instead (see Backups and restore) and rebuild the search index:

docker compose exec engine /app/engine-dmo search rebuild --project /srv/project

Deploying a change

A change to the project (configuration, templates, public files) is a new copy of ./project and a restart. A new engine version is a new MERIDIAN_IMAGE. Either way:

./backup.sh                               # before anything that migrates
docker compose up -d                      # migrate, schema apply, then the engine restarts

Compose re-runs migrate and schema because they exited; they do nothing when there is nothing to do. The engine is down between the old container stopping and the new one becoming healthy, usually a few seconds. For deploys without that gap, see Scaling.

Backups

backup.sh dumps the database in PostgreSQL’s custom format and archives the media volume. Schedule it daily from cron and copy its output off the machine:

#!/usr/bin/env bash
# Back up the database and the storage volume into BACKUP_DIR, keeping
# KEEP_DAYS of them. Copy BACKUP_DIR off the host afterwards.
set -euo pipefail
cd "$(dirname "$0")"
backup_dir=${BACKUP_DIR:-./backups}
keep_days=${KEEP_DAYS:-30}
project=${COMPOSE_PROJECT_NAME:-meridian}
stamp=$(date -u +%Y%m%dT%H%M%SZ)
mkdir -p "$backup_dir"

docker compose exec -T db pg_dump --format=custom --no-owner -U meridian meridian >"$backup_dir/meridian-$stamp.dump"
docker run --rm -v "${project}_storage:/storage:ro" -v "$(cd "$backup_dir" && pwd):/backup" busybox:1.37 \
	tar -czf "/backup/storage-$stamp.tar.gz" -C /storage .
find "$backup_dir" -name 'meridian-*.dump' -mtime +"$keep_days" -delete
find "$backup_dir" -name 'storage-*.tar.gz' -mtime +"$keep_days" -delete
echo "backed up to $backup_dir/meridian-$stamp.dump and storage-$stamp.tar.gz"
15 3 * * * cd /srv/meridian && ./backup.sh && rclone copy backups remote:meridian-backups

restore.sh and the restore procedure are in Backups and restore. The search index is not backed up; engine-dmo search rebuild recreates it from the database.

Monitoring this machine

  • Point an uptime check at https://<your domain>/health?readiness, which answers 200 when the engine is ready and 503 otherwise.
  • Run Prometheus on the Compose network and scrape engine:9090/_metrics. Monitoring lists the series and what to alert on.
  • Watch the disk: media, the database, Meilisearch and local backups all grow on it.

Without containers: systemd

The same topology runs with the binary under systemd and PostgreSQL, Meilisearch and Caddy (or nginx) installed on the machine. Install the binary as /usr/local/bin/meridian-engine, the project at /srv/meridian (with storage/ owned by the meridian user), and the environment (DATABASE_URL, SIGNING_KEY and the rest of .env) at /etc/meridian/engine.env, readable by root only.

[Unit]
Description=Meridian content engine
After=network-online.target postgresql.service
Wants=network-online.target

[Service]
Type=simple
User=meridian
Group=meridian
EnvironmentFile=/etc/meridian/engine.env
Environment=ENVIRONMENT=production LISTEN_ADDR=127.0.0.1:8080
ExecStart=/usr/local/bin/meridian-engine serve --project /srv/meridian
Restart=on-failure
RestartSec=5
TimeoutStopSec=30
# The engine writes only its storage root.
ReadWritePaths=/srv/meridian/storage
ProtectSystem=strict
ProtectHome=true
PrivateTmp=true
NoNewPrivileges=true
ProtectKernelTunables=true
ProtectKernelModules=true
ProtectControlGroups=true
RestrictAddressFamilies=AF_INET AF_INET6 AF_UNIX
RestrictNamespaces=true
LockPersonality=true
CapabilityBoundingSet=
AmbientCapabilities=
LimitNOFILE=65536

[Install]
WantedBy=multi-user.target

Migrations and the schema are a deploy step before each restart:

sudo -u meridian env $(cat /etc/meridian/engine.env) ENVIRONMENT=production \
  meridian-engine migrate up --project /srv/meridian
sudo -u meridian env $(cat /etc/meridian/engine.env) ENVIRONMENT=production \
  meridian-engine schema apply --project /srv/meridian
sudo systemctl restart meridian

With the reverse proxy on the same machine, set server.trusted_proxies: ["127.0.0.1/32"]. TimeoutStopSec=30 matches the engine’s default server.shutdown_grace of 30 seconds.