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:
| Service | Role |
|---|---|
caddy | Terminates TLS for SITE_ADDRESS with an automatic certificate and forwards everything to the engine. It starts once the engine is healthy |
storage-init | Gives the media volume to the engine’s user, then exits |
migrate | engine-dmo migrate up, once, then exits |
schema | engine-dmo schema apply, once, after migrate, then exits. A destructive plan stops here (see Database and migrations) |
engine | engine-dmo serve. Healthy once health --readiness passes |
db | PostgreSQL 16, UTF8, with its data in the pgdata volume |
search | Meilisearch, 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.
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).
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.