An operator runbook for backing up an unmask install and restoring it on a new host.
The paths below are the defaults — check your /etc/unmask/config.yml for the
actual values.
What to back up (and why)
Item
Path (default)
Why it matters
config.yml
/etc/unmask/config.yml
Holds bv_secret + captcha_secret_base and the DB connection. Losing or changing bv_secret invalidates every issued _bv cookie, so all current visitors are re-challenged at once (no outage, but a challenge spike). DB credentials live here too.
database
SQLite: /var/lib/unmask/unmask.sqlite (+ -wal / -shm) MariaDB/MySQL: the database named in db.mariadb.database (unmask by default; its tables are unmask_*)
The challenge-page logos uploaded in Settings (default and per site). Without them a restored install shows no logo until they are uploaded again.
You do not need to back these up — they are regenerated automatically:
/var/lib/unmask/nginx/*.inc (unmask render-nginx rebuilds them), the ban file
/var/lib/unmask/nginx/banned.txt (rewritten from the bans in the database when the daemon starts),
community-bans-*.map (re-pulled from the feed), and the placed
ngx_http_unmask_module.so (re-placed on reinstall / nginx start).
Container installs: everything above lives inside the volumes —
/etc/unmask (config + secrets), /var/lib/unmask (database) and, with automatic
HTTPS, /var/cache/nginx/unmask-acme (the Let's Encrypt account and certificates).
Back those volumes up and the table is covered — with the container stopped, since a live SQLite file can be
copied torn (or take the SQLite snapshot below from inside it first); run the CLI steps as
docker compose exec unmask unmask …. Restoring is restoring the
volumes and docker compose up -d — there is no host nginx to render or
reload (nginx inside the container picks up the rendered config on its own).
Backup
From 0.1.53 one command takes it all: unmask backup writes
unmask-backup-<yyyymmdd-hhmmss>.tar.gz (UTC) holding config.yml, the logos
uploaded on the Page design tab and, on SQLite, a consistent copy of the database, taken with
VACUUM INTO while the daemon keeps running. On SQLite it lands next to the database, otherwise
in the current directory, unless -out DIR names another one. On MariaDB/MySQL the archive holds the config and the logos;
dump the database with mysqldump (2b below). Take one before every package upgrade: the schema
only moves forward, so going back to an older version needs the database from before the upgrade.
By hand, which is also the way on releases before 0.1.53:
# 1. config + uploaded logos (cheap — do this often)
install -d -m 0700 /backup/unmask
cp -a /etc/unmask/config.yml /backup/unmask/
cp -a /var/lib/unmask/branding /backup/unmask/ 2>/dev/null || true
# 2a. SQLite — consistent snapshot WITHOUT stopping the daemon
sqlite3 /var/lib/unmask/unmask.sqlite ".backup '/backup/unmask/unmask.sqlite'"
# 2b. MariaDB/MySQL
mysqldump --single-transaction unmask > /backup/unmask/unmask.sql # the db.mariadb.database name
--single-transaction (InnoDB) gives a consistent dump without locking. The SQLite
.backup command is safe against a live writer — it copies pages under a read lock,
whereas a plain cp of a live SQLite file can capture a torn WAL. It works with any
sqlite3 version: from 0.1.52 the database carries indexes only SQLite 3.38 or newer can
read, so an older sqlite3 answers a query with “malformed database schema”
(the file is sound), while .backup copies the pages without reading them.
Restore
An unmask backup archive unpacks into the same three parts
(mkdir /tmp/unmask-restore && tar -xzf unmask-backup-….tar.gz -C /tmp/unmask-restore);
put them back as below.
Install the same unmask version that produced the backup (unmask version).
Restore config.ymlfirst — this preserves bv_secret, so already-issued _bv cookies keep validating and visitors are not mass-re-challenged.
Restore the database:
SQLite: stop the daemon, remove any leftover unmask.sqlite-wal / -shm, copy the file
back, chown unmask:unmask /var/lib/unmask/unmask.sqlite (the daemon runs as unmask and
cannot write a root-owned file), start.
MariaDB/MySQL: mysql unmask < /backup/unmask/unmask.sql.
Put the uploaded logos back in /var/lib/unmask/branding/.
Start / restart the unmask service; confirm with unmask doctor and curl -sf http://127.0.0.1:9477/unmask/healthz.
Switching the database driver (SQLite ⇄ MariaDB/MySQL)
unmask ships no built-in cross-driver data migration. Two options:
Clean switch (recommended when stats history is not precious)
Point db.driver (+ the MariaDB/MySQL connection) in config.yml at the new driver,
run unmask migrate to create the schema there, and restart. The new database starts
empty — historical events / stats are left behind, and admin users + bans must be
re-created (or imported manually). Easiest done early, before stats accumulate. The admin
reconfigure wizard (/unmask/admin/setup/ on a configured install) does the same with a UI.
Preserve data (manual)
Dump the old driver, hand-translate the SQL dialect differences (SQLite ↔ MariaDB/MySQL types /
quoting), and load into the schema created by unmask migrate. This is version-specific
and unsupported as a one-command path; verify row counts afterward.
Upgrades & verifying
unmask migrate (run by the daemon on start, or manually) is idempotent and
re-runnable, which is what makes it safe on MariaDB/MySQL where DDL is non-transactional
(each ALTER / CREATE auto-commits and cannot roll back). Schema changes are numbered
migrations, each recorded in schema_migrations once applied and skipped from then on; the base schema is
CREATE TABLE IF NOT EXISTS with check-then-apply steps. After an interruption, re-running
unmask migrate continues from the first migration not yet recorded.
Verify
unmask doctor # DB ping + schema + config checks
unmask version # confirm the restored binary version