Skip to main content

Back up a single-server site (SQLite and local files)

If your site uses the defaults, SQLite for the database and the server's own disk for files, everything lives in one folder, the data directory. This page shows three ways to copy it, a script to run on a schedule, and how to set up a timer. It is for whoever runs the server. Read What to back up first for the full list, including your settings.

Why not just copy the database file?​

SQLite in this site runs in "write-ahead log" mode. While the site is running, recent changes sit in two companion files beside the database, choir.sqlite-wal and choir.sqlite-shm. Copying only choir.sqlite while the site runs can miss recent changes. Copying all three files one at a time while the site is changing can give you three files that do not fit together. So use one of these instead:

  • stop the site, then copy the folder; or
  • ask SQLite itself for a consistent copy while the site runs.

Method 1: stop and copy​

The simplest, and the right choice when a few minutes of downtime is fine, such as at night.

  1. Stop the site (sudo systemctl stop choirmaster, or stop the process you started).

  2. Copy the whole data directory:

    cp -a /srv/choirmaster/data /srv/backups/choirmaster-data-$(date +%Y%m%d)
  3. Start the site again.

When the site stops cleanly, SQLite folds the companion files into choir.sqlite and removes them, leaving just choir.sqlite and the storage folder (and, if you have updated by button, backups and releases).

Method 2: a consistent copy while the site runs​

Uses SQLite's own VACUUM INTO, which writes a clean, complete copy of the database even while the site is using it. It needs the same node that runs the site (Node 22.13 or newer), and nothing else installed.

The script​

Save this as /usr/local/bin/choirmaster-backup.sh and edit the three lines at the top.

#!/bin/sh
# Back up a Choir Master CMS site that uses SQLite and local files.
# Safe to run while the site is running.
set -eu

DATA=/srv/choirmaster/data # your DATA_DIR
DEST=/srv/backups/choirmaster # where the copies go (ideally another disk)
KEEP=14 # how many copies to keep

umask 077
stamp=$(date +%Y%m%d-%H%M%S)
work="$DEST/$stamp.partial"
mkdir -p "$work"

# Files first, then the database: every file the database mentions is then in the copy.
# A new site has no storage folder until the first upload.
if [ -d "$DATA/storage" ]; then
tar -czf "$work/storage.tar.gz" -C "$DATA" storage
fi

# A consistent copy of the database, made by SQLite itself
node --input-type=module -e "
import { DatabaseSync } from 'node:sqlite'
const [from, to] = process.argv.slice(1)
const db = new DatabaseSync(from)
db.exec('PRAGMA busy_timeout = 5000')
db.prepare('VACUUM INTO ?').run(to)
db.close()
" "$DATA/choir.sqlite" "$work/choir.sqlite"

# The copy is complete: give it its real name, then keep only the newest ones
mv "$work" "$DEST/$stamp"
ls -1d "$DEST"/*[0-9]/ | sort -r | tail -n +"$((KEEP + 1))" | while read -r old; do rm -rf "$old"; done
echo "Backup written to $DEST/$stamp"
sudo chmod 755 /usr/local/bin/choirmaster-backup.sh
sudo /usr/local/bin/choirmaster-backup.sh

Each run makes a folder like /srv/backups/choirmaster/20261011-020000/ holding choir.sqlite and storage.tar.gz. A copy that is still being written has .partial on its name and is never counted as a finished backup. The script prints the folder when it finishes. Run it as the user who owns the data directory, or as root.

Notes on the script:

  • VACUUM INTO refuses to overwrite a file that exists, so the script writes into a new folder each time.
  • If DATA or the database path is wrong, it stops with an error rather than writing an empty backup.
  • The copy contains everything in the database, including sessions and unused login links. That is harmless.
  • It does not copy DATA_DIR/backups or DATA_DIR/releases, which you do not need.
  • The script keeps the newest KEEP copies on the same machine. For the offsite copy see below.

Schedule it​

With cron (as root: sudo crontab -e), every night at 02:30:

30 2 * * * /usr/local/bin/choirmaster-backup.sh >> /var/log/choirmaster-backup.log 2>&1

With a systemd timer. Create /etc/systemd/system/choirmaster-backup.service:

[Unit]
Description=Back up Choir Master CMS

[Service]
Type=oneshot
ExecStart=/usr/local/bin/choirmaster-backup.sh

and /etc/systemd/system/choirmaster-backup.timer:

[Unit]
Description=Nightly Choir Master CMS backup

[Timer]
OnCalendar=*-*-* 02:30:00
Persistent=true

[Install]
WantedBy=timers.target

Then:

sudo systemctl daemon-reload
sudo systemctl enable --now choirmaster-backup.timer
systemctl list-timers choirmaster-backup.timer

Check a backup​

Open a copy of the database and make sure SQLite finds nothing wrong:

node --input-type=module -e "
import { DatabaseSync } from 'node:sqlite'
const db = new DatabaseSync(process.argv[1])
console.log(db.prepare('PRAGMA integrity_check').get())
" /srv/backups/choirmaster/20261011-020000/choir.sqlite

It prints integrity_check: 'ok'. To see what is in the files archive: tar -tzf storage.tar.gz | head.

Method 3: a Docker site​

The SQLite stack keeps everything in the volume data, mounted at /app/data. Its real name starts with the Compose project name, which by default is the name of the folder holding the compose file, so choirmaster_data for a folder called choirmaster. Find yours with docker volume ls.

Stop and copy the volume​

Stop the app first so the database is not in use:

docker compose -f docker-compose.sqlite.yml stop app
docker run --rm -v choirmaster_data:/data -v "$PWD":/backup alpine tar czf /backup/choir-backup.tgz /data
docker compose -f docker-compose.sqlite.yml start app

Copying the volume while the app is running can give you a database copy that is missing recent changes or does not fit together, for the reason above. Stopping first avoids it.

A consistent copy without stopping​

The same SQLite call, run inside the container, writes into the folder backups in the volume. Then copy it out:

docker compose -f docker-compose.sqlite.yml exec app mkdir -p /app/data/backups
docker compose -f docker-compose.sqlite.yml exec app node --input-type=module -e "
import { DatabaseSync } from 'node:sqlite'
const [from, to] = process.argv.slice(1)
const db = new DatabaseSync(from)
db.exec('PRAGMA busy_timeout = 5000')
db.prepare('VACUUM INTO ?').run(to)
db.close()
" /app/data/choir.sqlite /app/data/backups/manual.sqlite
docker compose -f docker-compose.sqlite.yml cp app:/app/data/backups/manual.sqlite ./choir-$(date +%Y%m%d).sqlite
docker compose -f docker-compose.sqlite.yml exec app rm /app/data/backups/manual.sqlite
docker compose -f docker-compose.sqlite.yml cp app:/app/data/storage ./storage-copy

Remember to copy the .env file too.

Keep a copy somewhere else​

A backup on the same disk is not enough. Copy the finished folder to another machine, for example with rsync:

rsync -a --delete-after /srv/backups/choirmaster/ backup-user@backup.example.org:choirmaster/

or to a bucket with the command-line tool of your storage provider. Whatever you use, test a restore: Restore a backup, or move to a new server.