Backups and updates

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.

What to back up

  • The data volume. It holds the database, which is a SQLite file, and every file uploaded to the media library. It is the one thing you cannot rebuild. The SQLite file is really three files: 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.
  • The .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.
  • The site folder, in git. Push often. It holds site.config.ts, your theme and blocks, compose.yaml and the lock file.
  • The Postgres database, if the site uses one. It is not in the data volume; see “Back up Postgres” below.

The caddy_data volume, with the certificates, is not worth backing up. Caddy gets new ones if it has to.

Back up the data

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.

  1. Make a folder for backups, outside the site folder so that git never sees them.
mkdir -p ~/backups/my-site
  1. Stop the site, write the backup and start the site again. docker 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 app
  1. Check the file. It should list site.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.gz

If you have a lot of uploads, the site stays down for as long as the copy takes.

Back up every night

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.

  1. Create 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.
backup.sh
#!/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.sh
  1. Run it once by hand and check that it printed wrote and a file name.
./backup.sh
  1. Open your crontab, the list of jobs the server runs on a schedule.
crontab -e
  1. Add this line at the end, with your own user name in the paths. It runs the script at half past three every night and keeps what it prints in a log file.
crontab
30 3 * * * /home/you/my-site/backup.sh >> /home/you/backup.log 2>&1

A 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/

Restore a backup

A restore replaces everything in the data volume with what the backup holds. Anything made on the site after the backup is lost.

  1. Choose the backup file. The file name in the next command is an example; use one of yours.
  2. Stop the site, clear the data volume, unpack the backup into it and start the site. The volume is cleared first, because a leftover file from the newer database would not match the restored one.
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
  1. Check that the site is healthy and that your content is back. You should see "ok":true.
curl -s https://example.com/api/health

Try 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.

Move to a new server

The same backup file moves a site to another server.

  1. Set up the new server as in Run it on a server: Docker, the firewall and the site folder from git. Copy the old server's .env to the new one, so that CMS_AUTH_SECRET stays the same. Do not start the site yet.
  2. Copy the newest backup to the new server, then build the image.
scp ~/backups/my-site/my-site-2026-10-11-0330.tar.gz you@198.51.100.20:
docker compose build
  1. Load the backup into the new server's data volume. Docker makes the volume if it does not exist yet. Then start the site.
docker 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 -d
  1. Point your domain's DNS at the new server. In https mode Caddy cannot get a certificate for the domain until the name points at the server, so expect a short while with a certificate warning. Lower the record's time to live a day or so beforehand to make the switch quicker, and keep the old server until the new one works.

Back up Postgres

If 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.gz

To 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 app

Back up the data volume as well. With Postgres it holds only the uploads.

Update the cms

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.

  1. On the server, back up the data and remember the image the site runs now. The tag lets you go back quickly. 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
  1. On your computer, in the site folder, update every cms package to its newest release. The pattern matches @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 0

pnpm 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.0
  1. Try it on your computer. Starting the development server applies the new migrations to your local data.
pnpm dev
  1. Commit the new versions, with the lock file, and push.
git add package.json pnpm-lock.yaml
git commit -m "Update the cms"
git push
  1. On the server, pull the change and rebuild. The site restarts, which takes about twenty seconds, and applies the migrations on its first request.
git pull
docker compose up -d --build
  1. Check that the site is healthy and that no migrations are waiting. You should see "ok":true and "pendingMigrations":[].
curl -s https://example.com/api/health

Settings → System in the admin shows the version the site runs.

Roll back an update

What to do depends on how far the update got.

  • If the new version fails while it applies a migration, nothing changed. Each run of migrations is a single transaction that is undone as a whole, and the database stays as it was.
  • If the new version started but misbehaves, put the old image back, and the old data as well when the new version had already changed it.
  1. Start the old image again, without building. This is quick, because the old image is still on the server.
docker tag my-site:before-update my-site
docker compose up -d --no-build
  1. If the new version had already applied migrations, the old version may not work with the changed database. Restore the backup you took in step 1 of the update, as in “Restore a backup” above. Changes made since then are lost.
  2. Make git match what runs. On your computer, undo the update commit and push; then pull on the server. Do not run docker compose up -d --build on the new commit until you want the new version again.
git revert HEAD
git push

Without 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.

Next

If something does not work, Troubleshooting lists the problems new sites run into most often, with the commands that show the cause.