Backups and restore
What a complete Meridian backup contains (the database, the media store, the project and the signing key), scripts to take one and restore it, the order that keeps them consistent, and exporting one site as a standalone install.
A Meridian install is restored from four things. Lose any one and the restore is incomplete.
| What | Holds | How to keep it |
|---|---|---|
| The PostgreSQL database | Content and every revision, users, the CRM, analytics, the media library’s records, the job queue | pg_dump (below), or your managed service’s backups and point-in-time recovery |
| The media store | Every uploaded original and generated derivative | Archive the storage root, or rely on the bucket’s versioning or replication |
| The project | Configuration, templates, public files | Your git repository, or the image you deploy |
server.security.signing_key | The key staff second factors are encrypted with | Your secret store. A database restored under a different key leaves every staff authenticator unusable until reset |
The search index is not backed up. It is rebuilt from the database with engine-dmo search rebuild.
The engine has no backup or restore command of its own: a backup is a pg_dump of the database plus a copy of the media store, taken with your tools, in this order, and a restore puts them back in this order:
flowchart TD
subgraph take["Taking a backup"]
dump["pg_dump, custom format"] --> files["archive the storage root"]
files --> off["copy both off the machine"]
end
subgraph back["Restoring"]
stop["stop every replica"] --> db["recreate the database,<br/>pg_restore"]
db --> store["restore the storage root"]
store --> key["set the same SIGNING_KEY"]
key --> mig["migrate up, schema apply"]
mig --> boot["start, wait for readiness"]
boot --> idx["search rebuild"]
end
off -.-> dbTaking a backup
Dump the database first, then archive the store. Originals are named by their content and never rewritten, so a store copied after the dump holds every file the dump refers to.
pg_dump --format=custom --no-owner "$DATABASE_URL" > meridian-$(date -u +%Y%m%dT%H%M%SZ).dump
tar -czf storage-$(date -u +%Y%m%dT%H%M%SZ).tar.gz -C /srv/meridian/storage . Use a pg_dump of the same major version as the server, or newer. Back up at least daily, keep copies off the machine and in another region, and keep enough history to recover from a mistake noticed late (30 days is a common minimum).
With the s3 driver, turn on the bucket’s versioning (or replicate it to a second bucket) rather than copying it on a schedule, and keep deleted versions for as long as you keep database backups.
With Docker Compose
For the single-node deployment, backup.sh beside compose.yaml does both steps inside the stack:
#!/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" Restoring
A restore replaces what is there. Rehearse it on a scratch machine before you need it, and after every engine upgrade.
Stop the engine (every replica) so nothing writes during the restore.
Recreate the database with UTF8 encoding and restore the dump into it:
dropdb --if-exists meridian
createdb -E UTF8 -T template0 meridian
pg_restore --exit-on-error --no-owner -d meridian meridian-20261102T031500Z.dump Restore the store: empty the storage root and unpack the archive into it, owned by the engine’s user.
Set the same SIGNING_KEY (and MEDIA_SIGNING_KEY if you set one) as the install the backup came from.
Bring the database up to the binary. A backup from an older engine needs its migrations: engine-dmo migrate up, then engine-dmo schema apply.
Start the engine and wait for engine-dmo health --readiness.
Rebuild the search index: engine-dmo search rebuild. Until it finishes, search answers from what the index held before.
With Docker Compose
restore.sh takes the two files backup.sh wrote and restores them into the running stack:
#!/usr/bin/env bash
# Restore from backup.sh's two files, replacing the database and the
# storage volume, then start the engine:
# ./restore.sh backups/meridian-<stamp>.dump backups/storage-<stamp>.tar.gz
set -euo pipefail
cd "$(dirname "$0")"
dump=${1:?usage: restore.sh <meridian.dump> <storage.tar.gz>}
archive=${2:?usage: restore.sh <meridian.dump> <storage.tar.gz>}
project=${COMPOSE_PROJECT_NAME:-meridian}
docker compose stop engine caddy
docker compose exec -T db dropdb -U meridian --if-exists meridian
docker compose exec -T db createdb -U meridian -E UTF8 -T template0 meridian
docker compose exec -T db pg_restore --exit-on-error --no-owner -U meridian -d meridian <"$dump"
docker run --rm -i -v "${project}_storage:/storage" busybox:1.37 \
sh -c 'find /storage -mindepth 1 -delete && tar -xzf - -C /storage && chown -R 65532:65532 /storage' <"$archive"
docker compose up -d
echo "restored; rebuild the search index with: docker compose exec engine /app/engine-dmo search rebuild --project /srv/project" docker compose up -d runs the migrate and schema services before the engine, so a backup from an older engine is brought up to date on the way.
Restoring under a running engine
Never restore into the database a running engine is using. If it happens, the readiness check fails as soon as a migration the binary knows is missing from the restored database, and a load balancer stops sending traffic to it; restart the engine after migrate up.
Exporting one site
On a multi-site install, one site can leave as a standalone install of its own: its database rows, its media and its configuration, verified by restoring the export into a scratch database and serving its home page.
engine-dmo site export --site wyndwold --plan
engine-dmo site export --site wyndwold --out ./wyndwold-export \
--scratch-database-url "$SCRATCH_DATABASE_URL" \
--signing-key-env NEW_INSTALL_SIGNING_KEY | Flag | Meaning |
|---|---|
--site | The site’s key |
--out | The directory to write, created with mode 0700; it must be empty or absent |
--plan | Print what would be copied and stop |
--scratch-database-url | A PostgreSQL server where the export may create two databases, and drop them, to verify itself |
--signing-key-env | The environment variable holding the receiving install’s signing key, to reseal staff second factors with; without it they are cleared |
--staff | A staff user whose grants reach every site, by address, to copy as well. Repeatable |
engine-dmo site retire --site <key> then removes the site from this install (--plan first; it cannot be undone). The site export is not a substitute for backups: it carries one site, not the install. It keeps the site’s audit rows without their hash chain links.
Related
- Database and migrations
- Storage and media
- Upgrades: back up before every upgrade.
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.
Data protection duties
The data protection work that falls to whoever runs an install, answering a subject access request, generating the impact assessment, and turning on and verifying the audit log's hash chain, with what the chain cannot detect.