Disks fail, and updates sometimes go wrong. This chapter shows how to copy your site's data somewhere safe and put it back, how to move the site to a new server, and how to update the cms and undo the update if it goes badly. At the end you have a backup that runs every night and a restore you have tried.
The commands assume the setup from Run it on a server, with the site in a folder called my-site on the server. As there, replace you, 203.0.113.10, example.com and the example file names with your own.
site.db and two helpers, site.db-wal and site.db-shm. Recent changes can sit in the -wal file for a while, so a copy of site.db on its own can be missing them. Copy all three together, as the commands below do..env file. It holds your secrets and is not in git. Keep a copy in a password manager. If you lose CMS_AUTH_SECRET and make a new one, everyone is signed out and the preview links already sent stop working.site.config.ts, your theme and blocks, compose.yaml and the lock file.The caddy_data volume, with the certificates, is not worth backing up. Caddy gets new ones if it has to.
The safest copy of a SQLite site stops the site for a few seconds, packs the data volume into one file and starts the site again. While it is stopped, visitors see an error page.
mkdir -p ~/backups/my-sitedocker compose run starts a one-off container that shares the data volume. tar packs /data and writes the result to its output, which the shell saves as a file on the server.docker compose stop app
docker compose run --rm --no-deps -T --entrypoint tar app czf - -C /data . > ~/backups/my-site/my-site-$(date +%F).tar.gz
docker compose start appsite.db and its two helpers, and the media folder once you have uploaded something.ls -lh ~/backups/my-site
tar tzf ~/backups/my-site/my-site-$(date +%F).tar.gzIf you have a lot of uploads, the site stays down for as long as the copy takes.
A backup you have to remember to make does not get made. This script does what the commands above do, keeps the newest fourteen files and deletes the older ones. It starts the site again even if the copy fails.
backup.sh in the site folder on the server with an editor, for example nano backup.sh. Paste this in and save it. Then make it executable.#!/usr/bin/env bash
# Copies the site's data (database and uploads) into one file and keeps the newest 14.
set -euo pipefail
cd "$(dirname "$0")" # the site folder, wherever cron runs this from
backups="$HOME/backups/my-site"
mkdir -p "$backups"
file="$backups/my-site-$(date +%F-%H%M).tar.gz"
# The site stops while its data is copied, and starts again even if the copy fails.
trap 'docker compose start app' EXIT
docker compose stop app
docker compose run --rm --no-deps -T --entrypoint tar app czf - -C /data . > "$file" || { rm -f "$file"; exit 1; }
# Delete all but the newest 14.
ls -1t "$backups"/*.tar.gz | tail -n +15 | xargs -r rm --
echo "wrote $file"chmod +x backup.shwrote and a file name../backup.shcrontab -e30 3 * * * /home/you/my-site/backup.sh >> /home/you/backup.log 2>&1A backup on the same disk as the site does not help when the disk or the server is lost. Copy the folder to another machine as well. This is one way, run from that other machine:
rsync -av you@203.0.113.10:backups/my-site/ ~/site-backups/my-site/A restore replaces everything in the data volume with what the backup holds. Anything made on the site after the backup is lost.
docker compose stop app
docker compose run --rm --no-deps -T --entrypoint sh app -c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < ~/backups/my-site/my-site-2026-10-11-0330.tar.gz
docker compose start app"ok":true.curl -s https://example.com/api/healthTry this once before you need it, on a copy of the site or at a quiet moment, so that you know the steps work and how long they take. A good test is to add an account, restore the backup and check that the new account is gone and the older ones are back.
The same backup file moves a site to another server.
.env to the new one, so that CMS_AUTH_SECRET stays the same. Do not start the site yet.scp ~/backups/my-site/my-site-2026-10-11-0330.tar.gz you@198.51.100.20:docker compose builddocker compose run --rm --no-deps -T --entrypoint sh app -c 'find /data -mindepth 1 -delete && tar xzf - -C /data' < ~/my-site-2026-10-11-0330.tar.gz
docker compose up -dIf you followed the Postgres steps in Going live, the database lives in the db service and not in the data volume. pg_dump makes a consistent copy while the site runs, so nothing has to stop:
docker compose exec -T db pg_dump -U cms --clean --if-exists cms | gzip > ~/backups/my-site/db-$(date +%F).sql.gzTo restore it, stop the site, load the dump and start the site. The dump deletes and recreates the tables, and --single-transaction undoes the whole restore if anything fails.
docker compose stop app
gunzip -c ~/backups/my-site/db-2026-10-11.sql.gz | docker compose exec -T db psql -U cms -d cms --single-transaction -v ON_ERROR_STOP=1 -q
docker compose start appBack up the data volume as well. With Postgres it holds only the uploads.
New versions of the cms come out as a set of packages whose names start with @jamiemcconnell/cms. They always share one version number, and a site must use the same version of all of them. An update changes those numbers in package.json, rebuilds the image and restarts the site. Database migrations, the changes a new version needs in the database, are applied when the site starts. They only go forward, so take a backup first.
Before a new major version, which is when the first number changes, read its release notes. A major version can ask for changes in site.config.ts. A site that still has an old setting refuses to start with the error CONFIG_INVALID and the log says where the setting went.
my-site is the name of the image, which is the name of the site folder../backup.sh
docker tag my-site my-site:before-update@jamiemcconnell/cms and every package that starts the same way, such as the feature and adapter packages you installed.pnpm up --latest "@jamiemcconnell/cms*"Look at what changed. You should see new version numbers for each cms package in package.json, all the same.
git diff package.json
pnpm list "@jamiemcconnell/*" --depth 0pnpm does not take a release until it is a day old. If the version came out within the last day, pnpm up says Already up to date and changes nothing. Name the version instead, for each cms package your site has. Replace 1.12.0 with the version you want, and list every package that the command above printed:
pnpm add @jamiemcconnell/cms@1.12.0 @jamiemcconnell/cms-shop@1.12.0pnpm devgit add package.json pnpm-lock.yaml
git commit -m "Update the cms"
git pushgit pull
docker compose up -d --build"ok":true and "pendingMigrations":[].curl -s https://example.com/api/healthSettings → System in the admin shows the version the site runs.
What to do depends on how far the update got.
docker tag my-site:before-update my-site
docker compose up -d --no-builddocker compose up -d --build on the new commit until you want the new version again.git revert HEAD
git pushWithout the tag, go back by reverting the commit as above and rebuilding on the server with git pull and docker compose up -d --build. That takes as long as any other build.
If something does not work, Troubleshooting lists the problems new sites run into most often, with the commands that show the cause.