NotariumDocumentation
Documentation version: latest
EN

Backup and restore

A Notarium backup is a built-in image command, not a file copy taken from the outside. notarium backup assembles a logical ZIP while the service keeps serving: reads stay available throughout, and writes are held back only for two brief checkpoints. All you need is Docker and a running container — no downtime.

Never copy a live meta.db

cp /data/meta.db is not a backup. The metadata DB runs in WAL mode: committed rows may still be sitting in meta.db-wal, and files copied one at a time are not a snapshot of a single moment. An instance restored from such a copy loses data silently.

Take a backup

docker compose exec -T notarium backup > notarium-20260731.zip

This is a real backup, not a "does it work" check: the command builds the archive, runs it through its own verifier, and only then streams the bytes out. The -T flag is mandatory — without it Compose allocates a pseudo-terminal, a binary stream doesn't survive one intact, and you end up with a corrupt archive. Plain docker exec allocates no pseudo-terminal by default, so the flag isn't needed there. Standard output is reserved strictly for ZIP bytes; progress and the closing summary go to stderr and never contaminate the archive.

For a scheduled job

Redirection with > has one trap: the file under its final name is created before the command has done any work. If Docker or the backup dies halfway through, you're left with a correctly named file holding incomplete content. Below is a publish sequence that's safe under failure: write to a temporary file, flush it to disk, then publish with an atomic hard link.

backup="notarium-$(date -u +%Y%m%dT%H%M%SZ).zip"
partial="${backup}.partial.$$"
set -eu
umask 077
committed=0
cleanup() { test "$committed" -eq 1 || rm -f "$partial"; }
trap cleanup EXIT
docker compose exec -T notarium backup > "$partial"
sync -f "$partial"
sync -f "$(dirname "$backup")"
committed=1
ln "$partial" "$backup"
if sync -f "$(dirname "$backup")"; then
  if rm "$partial"; then
    sync -f "$(dirname "$backup")" ||
      echo "backup warning: final is durable; partial cleanup fsync failed" >&2
  else
    echo "backup warning: final is durable; retaining recovery partial $partial" >&2
  fi
else
  echo "backup warning: final is visible; retaining durable recovery partial $partial" >&2
fi
trap - EXIT

set -e keeps the archive from being published if Docker or the backup returned an error. The temporary name carries the PID, so two jobs running at once won't fight over the same file. The temporary file and its directory are flushed to disk before the publish point, and the publish itself is an atomic hard link that never overwrites: two jobs aiming at the same target name can't clobber each other.

Failures after the publish point are warnings, not false alarms: the final file is in place, and in ambiguous cases the temporary file is kept as a spare copy. Even an ambiguous non-zero exit from ln keeps it — the link may have been created just before the process was interrupted. umask 077 makes the archive readable by its owner only. Keep the temporary and the final file on the same filesystem.

For a container started with plain docker run under the name notarium, everything above holds — one line changes:

docker exec notarium backup > "$partial"
If the container can already see your backup directory

The wrapper above exists because the stdout transport can't publish a file by itself. When the archive directory is already mounted — a backup share, NFS, a scratch volume alongside a read-only root — backup --output /path/archive.zip does the same job: it writes to a temporary file next to the target, flushes it to disk, verifies it, and publishes with an atomic hard link that never overwrites; on failure it leaves nothing behind under the target name. No shell wrapper needed then, and stdout carries a single JSON summary.

Backup needs a running container

The command is run with docker exec inside the container that's already serving — not as a separate container: taking a consistent snapshot requires coordinating with the live application.

When a backup can fail

The backup is built so that you never silently end up with an inconsistent archive: if the snapshot can't be taken, the command exits with an error and publishes nothing. Two cases where that happens:

  • A continuous stream of edits. The data has to hold still while the archive is assembled; an overlapping write triggers a retry, and under an endless stream of edits the command gives up with an error. In practice you'll see this on a busy instance — just retry later.
  • A long import or export is running. The backup needs a pair of very short pauses in writes, and a long-running job doesn't fit into them. Don't schedule a backup in the same window as a bulk import.

Neither case hurts the service: the write queue is released immediately, and the operator command never holds the application up. Ordinary reads stay available for the whole duration of a backup either way.

What's inside the archive

Path in the archiveWhat it is
data/meta.dbAccounts, sessions, membership, stable identifiers, version history, and job state.
data/spaces/The Markdown source of truth, including agent memory and project marker files.
data/jobs/Finished artifacts and durable import uploads.
manifest.jsonFormat version, timestamp, the exact set of directories, plus size, mtime, and SHA-256 for every file.

The derived data/engine/ directory is not included: indexes are rebuilt from files after a restore. Of the incomplete files, only internal ones are skipped — the temporary files of atomic note writes, partially uploaded imports, and chunks of export artifacts. Ordinary user files whose names end in .part stay in the archive: they're legitimate.

The archive is sensitive data

The ZIP holds accounts and session state from the metadata DB. Treat it as a secret: umask 077 in the snippet above makes a new archive readable by its owner only.

Verification

Verification changes nothing, and it belongs in every backup job:

docker compose exec -T notarium backup verify < notarium-20260722.zip

# for plain docker run:
docker exec -i notarium backup verify < notarium-20260722.zip

On success the command prints a single JSON summary and exits with zero. It rejects unsafe and duplicate paths, files missing from the manifest, a directory set that doesn't match exactly, size and hash mismatches, invalid time metadata, limit overruns, and a failed SQLite integrity check. The live data directory is neither read nor modified in the process.

Checksums are not a signature

Hashes catch accidental corruption, but they don't protect against tampering: anyone able to replace both the content and the manifest will pass verification. Treat backup storage as trusted state with restricted access, or add signing or encryption in whatever layer moves the archive around.

Restore

Restore is an offline disaster operation. It accepts only a clean, empty data root, and it never merges with an existing instance or overwrites one.

Prepare a fresh volume and point the service at it before starting:

set -eu
docker compose stop notarium
# move the old volume aside; mount an empty /data in compose
docker compose run --rm --no-deps -T notarium restore \
  < notarium-20260722.zip
docker compose up -d --force-recreate --no-deps notarium

The container has to be recreated after that, not just started: docker compose start would bring back the old container with its old mount configuration. Keep the old volume until you've checked the restored instance.

Restore verifies the whole archive before installing anything. If the process is interrupted mid-install, it leaves an explicit marker behind: treat that target as single-use and restore into a new empty one instead of pushing through or merging.

What to check after a restore:

  1. Sign in with an account from the backup.
  2. Open a few spaces and confirm that addresses and identifiers are intact.
  3. Open a note you had edited and look at its history.
  4. Check the import and export jobs whose uploads or artifacts matter to you.
Metadata DB schema compatibility

The metadata DB you're restoring must carry a migration registry that the target build accepts. A non-empty DB with no registry fails closed — restore doesn't guess its version and won't stamp one in on its own. See Database.

Boundaries

The built-in command supports only the canonical layout: a single data root and a metadata DB in a SQLite file. If META_DB_URL points to Postgres, or notes live outside DATA_DIR, the command fails closed rather than handing you a partial archive — in that case use your database's own tooling plus snapshots of the mounted directories.

The intermediate files of a backup and a verification live in /tmp by default. A streaming backup verifies itself before publishing, so it may temporarily need room for the archive plus two expanded stages; a standalone verify needs the archive plus one. Restore buffers the incoming stream in scratch space, but expands the archive straight into the fresh data root itself. If the container's root filesystem is mounted read-only, or your dataset is large, point NOTARIUM_BACKUP_TMPDIR at a mounted writable directory.

Compressed and expanded input are capped at 64 GiB and one million entries; names, the ZIP's internal structures, and manifest.json have a separate 32 MiB memory limit. Trusted large installations can raise NOTARIUM_BACKUP_MAX_BYTES, NOTARIUM_BACKUP_MAX_ENTRIES, and NOTARIUM_BACKUP_MAX_METADATA_BYTES — see Environment variables.

Next

  • Image CLI — the full contract for commands, streams, and exit codes.
  • Database — exactly what the metadata DB holds and why it must never be left out of a backup.
  • Production — reverse proxy, the single-instance invariant, day-to-day operations.