Pre-release. bakelite is unreleased and still under active testing — docs and behaviour may change without notice.

CLI reference

Four flags are global — give them before or after the subcommand:

init

Guided, recipe-driven setup. Run it on a terminal and it walks you from a fresh install to a validated, running backup: pick a recipe, answer a few prompts, and it writes a config (validated before it's saved), optionally an at-rest encryption key and a chmod 600 credentials file, and a systemd drop-in with the correct User=/ReadWritePaths= (it reads the database file's owner to propose User=) — then runs doctor. Every step is confirmed; nothing is overwritten without asking, so it's safe to re-run. The one-line installer offers to launch it for you.

sudo bakelite init                        # interactive wizard
sudo bakelite init --recipe s3 --yes      # non-interactive: scaffold a starter config

It writes to --config (default /etc/bakelite/bakelite.toml).

Unattended setup

For CI, Ansible, cloud-init, or a Dockerfile, --yes with --db-path performs a complete local-backend setup with no prompts — the same config + systemd steps the wizard does, end to end:

sudo bakelite init --yes --force \
  --db-path /var/lib/app/app.db --db-name app \
  --backup-dir /var/backups/bakelite \
  --encrypt --service-user app --enable

(--force overwrites the placeholder config a package install drops at /etc/bakelite/bakelite.toml; omit it to refuse overwriting. It never overwrites an existing key file.)

This writes the config, generates the key, creates and chowns the backup dir to app, writes the systemd drop-in (correct User=/ReadWritePaths=, unmasking the unit if needed), re-homes the key so the daemon can read it, and starts the service. Flags: --db-path (triggers full setup) / --db-name / --backup-dir / --encrypt (or --key-file) / --service-user (writes the drop-in) / --service-group / --enable. Only the local/file backend is fully expressible from flags; for a cloud backend, hand-write the config and use --service-user/--enable against it. (CI exercises this exact path under real systemd as a non-root user — see .github/workflows/e2e.yml.)

daemon

Run the daemon for every database in the config. This is what the systemd unit runs; it watches each WAL and ships segments continuously. Pass --db <name> to back up just one.

bakelite daemon --config bakelite.toml
bakelite daemon --config bakelite.toml --max-concurrent-snapshots 4
bakelite daemon --config bakelite.toml --metrics-addr 127.0.0.1:9090

All three are also configurable under [daemon] in the TOML, so the systemd unit can stay generic. CLI flags override the config when both are set.

Metrics

When --metrics-addr is set, GET /metrics returns the standard Prometheus text exposition format. One sample per series per database (database label):

SeriesTypeMeaning
bakelite_upgauge1 while actively replicating, 0 otherwise.
bakelite_replica_lag_secondsgaugeRPO — seconds the replica is behind the live database. Omitted until both a -wal and a first sync exist; once present, shows 0 when caught up.
bakelite_last_synced_secondsgaugeSeconds since the replica was last known caught up.
bakelite_backing_off_secondsgaugeSeconds the database has been stuck in an unhealthy retry streak. Absent while healthy (so a healthy series isn't pinned at 0); present-and-climbing is the alert.
bakelite_change_setsgaugeChange-sets in the current backup's in-memory index.
bakelite_bytes_shipped_totalcounterBytes shipped to the backend this daemon run (resets on restart).
bakelite_last_verified_secondsgaugeSeconds since the replica was last verified clean (from the verify marker). Absent until the first clean verify.
bakelite_verify_okgauge1 if the most recent verify run was clean, 0 if it found problems.

Scrape with the standard static_configs:

scrape_configs:
  - job_name: bakelite
    static_configs:
      - targets: ['host:9090']

What to alert on

These alerts exist to catch a backup that has stopped without anyone noticing. A sensible starting set (tune the thresholds to your RPO):

No Prometheus? Use exit codes. The same two questions are answerable from cron or a systemd timer with no scraper, because both checks exit non-zero on trouble: bakelite status --check for liveness/freshness (it catches a stopped daemon, which an in-process alerter never can) and bakelite verify for integrity. See the monitoring recipe under status.

list

List what's restorable, top-down. One command covers the fleet, a single database's restore points, and (with --verbose) the full backup structure. Backend-only, so it's safe to run while the daemon is shipping.

bakelite list --config bakelite.toml                    # one row per database
bakelite list --db app --config bakelite.toml           # the database's restore points
bakelite list --db app --verbose --config bakelite.toml # + the full backup structure

status

Show what the running daemon is doing right now — read live from the status files the daemon publishes beside each database (.bakelite/<name>.status.json), not from the backend. list reports what's stored; status reports whether replication is healthy and current.

bakelite status --config bakelite.toml          # one row per database
bakelite status --db app --config bakelite.toml # just one
bakelite status --json --config bakelite.toml   # machine-readable
bakelite status --check --config bakelite.toml  # exit non-zero if anything is unhealthy

Each row shows the STATE, the FRESH column (live when caught up, -Ns/-Nm/etc. when behind — the replication RPO), how long ago the replica was last caught up (SYNCED), the count of change-sets shipped (CHANGES), and bytes shipped this run (SHIPPED). The state is one of:

Because stale and not running are derived from the status file's age and recorded PID, a daemon that has died or gone quiet shows one of those states rather than a healthy one.

When a database is unhealthy, the row is followed by a stuck for <time> (N failed attempt(s)) line and the retained last error: — so you can tell a daemon that's been wedged for hours from one idling happily (both can otherwise look "recent"), and see why it's stuck, even after it goes stale. If a verify has run, a verified: clean (<mode>) <time> ago line shows when the replica was last proven restorable (or verify: PROBLEMS (...) if the last run failed).

Monitoring without a scraper

bakelite status --check is the liveness twin of verify: it exits non-zero if any selected database isn't actively replicating (stopped, not running, stale, or backing off) or a threshold below is exceeded. It's silent on success (so a cron job mails nothing) and prints the failing databases and reasons on failure. Crucially, run from cron or a systemd timer it catches a fully stopped daemon — something no in-process alerter can, since a dead process sends no alerts. (--json still emits the full report alongside the exit code, for a scripted monitor.)

These schedules now ship as ready-to-enable systemd timers (bakelite-check.timer, bakelite-verify.timer, bakelite-verify-deep.timer) and an equivalent cron.d file (bakelite.cron, in the 6-field cron.d/system-crontab form with a user column) — see Operations → Scheduled verification and liveness for enabling them, choosing thresholds, and where the cron file goes on non-systemd hosts (busybox/macOS per-user crontabs drop the user column). The recipe below is exactly what those units run, written out by hand in the per-user-crontab form (5 time fields, no user column):

# Liveness + RPO + "has it been verified lately?" — silent unless unhealthy.
*/5 * * * *  bakelite status --check --max-lag 10m --max-verify-age 36h --config /etc/bakelite/bakelite.toml || notify-me "bakelite unhealthy"
# Nightly integrity drill; writes the verify marker the check above reads.
0   3 * * *  bakelite verify --deep --config /etc/bakelite/bakelite.toml || notify-me "bakelite backup corrupt"
# /etc/systemd/system/bakelite.service — catch the daemon's own death directly.
[Unit]
OnFailure=notify-failure@%n.service

If a database has async mirror destinations, each gets its own line under the row — in sync or N obj / <size> behind, with how long ago it last reconciled (or its last error) — read from the mirror loop's own status file (.bakelite/<name>.mirror.json). Lines are marked (stale) if that loop's daemon is gone, so a dead daemon's last reading never looks live. --json includes the full per-mirror detail.

usage

Report stored bytes per database, usage against any configured limits, and — on S3 — the version/multipart overhead a plain listing can't see. See Configuration for limits.

bakelite usage --config bakelite.toml             # one line per database
bakelite usage --db app --config bakelite.toml    # per-backup breakdown
bakelite usage --db app --json --config bakelite.toml

reclaim

Abort orphaned incomplete multipart uploads on S3 — snapshot uploads a crash left unfinalized, which are billed but invisible to a normal listing. The daemon does this automatically; this is the on-demand surface. Safe to run while the daemon is up: only uploads older than --min-age are touched, so an in-flight upload is never aborted. No-op on non-S3 backends and on hosts without static AWS env credentials.

bakelite never deletes object versions — to expire noncurrent versions on a versioned bucket, use a server-side lifecycle policy (see S3 storage overhead).

bakelite reclaim --config bakelite.toml                  # every database
bakelite reclaim --db app --dry-run --config bakelite.toml
bakelite reclaim --db app --min-age 0s --config bakelite.toml   # force-abort all

restore

Reconstruct a database file from the backend — latest state or a point in time. See Restore for the full target-resolution rules.

# Latest state, to a scratch path.
bakelite restore --db app --output /tmp/restored.db --config bakelite.toml

# Point in time — RFC3339 …
bakelite restore --db app --output /tmp/at.db \
  --timestamp 2026-05-25T12:00:00Z --config bakelite.toml

# … or a friendlier relative form.
bakelite restore --db app --output /tmp/an-hour-ago.db \
  --timestamp "1 hour ago" --config bakelite.toml

Restore on boot

The two guards make restore safe to run unconditionally in a container entrypoint or service ExecStartPre=: seed the database from the replica on a fresh volume, then get out of the way on every restart.

# First boot: the volume is empty → restore in place. Every later boot: the db
# exists → no-op (exit 0). New deployment with nothing backed up yet → no-op too.
bakelite restore --db app --if-db-not-exists --if-replica-exists --config /etc/bakelite/bakelite.toml

compact

Consolidate the replica's older incremental change-sets into fewer objects, keeping recent ones fine-grained per compaction_keep_recent. This is normally automatic (see compaction_levels); the command is a manual escape hatch. Backend-only and crash-safe.

bakelite compact --config bakelite.toml          # every database
bakelite compact --db app --config bakelite.toml # one database

verify

Check that a replica is intact and restorable — without touching the live database. verify confirms a replica actually restores, so you can schedule it to catch corruption before you need the backup. It is the cheap, scheduleable way to confirm it restores. Backend-only.

bakelite verify --config bakelite.toml             # every database
bakelite verify --db app --config bakelite.toml    # one database
bakelite verify --db app --deep --config bakelite.toml
bakelite verify --db app --json --config bakelite.toml

Shallow (the default) downloads every stored object and checks its integrity, then walks the change-set lineage chain from the full backup forward — catching a corrupted, missing, or reordered object. On an encrypted replica each object's AEAD is bound to its identity (database + position), so a deliberate substitution, relocation, or rollback of an object fails to decrypt and is detected (on a plaintext replica the chain catches accidental corruption, not a determined attacker). verify also warns when CURRENT doesn't point at the newest restore point — a possible rollback. --deep restores each backup to a temp file and runs PRAGMA integrity_check: the full end-to-end drill (slower — it replays everything).

verify exits non-zero if anything is wrong, so it drops straight into a cron job or healthcheck:

# Daily deep drill; alerts on failure via the non-zero exit.
0 4 * * *  bakelite verify --db app --deep --config /etc/bakelite/bakelite.toml

Each run also writes a small marker beside the cursor (.bakelite/<name>.verify.json) recording when the replica was last verified clean. bakelite status surfaces it (verified: clean … ago), the metrics endpoint exposes it (bakelite_last_verified_seconds, bakelite_verify_ok), and status --check --max-verify-age gates on it — so a cron-driven verify shows up in the live pane and can raise a freshness alert if it stops running.

repair

Heal bit-rot across destinations. For a database replicated to 2+ destinations, repair scans every backup object on each one and rewrites a healthy copy over any object that is corrupt or missing, so it's fixed before a restore depends on it. Backend-only, and safe with the daemon running (the heal is an idempotent byte-for-byte copy).

bakelite repair --db app --config bakelite.toml             # heal one database
bakelite repair --db app --dry-run --config bakelite.toml   # report, change nothing
bakelite repair --config bakelite.toml                      # every database
bakelite repair --db app --json --config bakelite.toml

A single-destination database is a no-op (no sibling to heal from). repair exits non-zero if an object is corrupt on every destination (only a fresh backup can replace it) or if a degraded copy couldn't be overwritten — e.g. on a WORM / Object-Lock'd bucket, where the immutable rotted object is reported as a degraded destination rather than a hard failure. It pairs with verify, which detects and reports a rotting destination; restore also falls back to a healthy copy automatically at read time. See Redundancy & bit-rot recovery.

keygen

Generate a fresh BAKELITE-KEY-V1-… key for at-rest encryption (XChaCha20-Poly1305). Used with [database.encryption] / [defaults.encryption].

# Write to a file (mode 0600, refuses to overwrite an existing file).
bakelite keygen --output /etc/bakelite/key.txt
# Or to stdout.
bakelite keygen

Keep the file safe: it's symmetric — the same key encrypts and decrypts. Lose it and the backups it encrypted are unrecoverable, so store a copy off-host — somewhere independent of both the database host and the backup bucket (see Keep an off-host copy of the key).

reencrypt

Rewrite every backend object under the currently configured encryption mode. One primitive covers three operations:

It's daemon-down per database: it acquires the same single-instance lock the daemon holds and refuses (with a clear message) if a daemon is still running for that database — stop it first. Reruns are idempotent: already-correct objects are skipped, so an interrupted run is safe to repeat. Without --db, every configured database is reencrypted in turn. Backend-only; exits non-zero on failure.

# Encrypt a previously-plaintext replica under the now-configured key.
sudo systemctl stop bakelite
bakelite reencrypt --db app --config bakelite.toml
sudo systemctl start bakelite

# Rotate from an old key to the configured one (preview first).
bakelite reencrypt --db app --old-key-file /etc/bakelite/key-old.txt --dry-run --json
bakelite reencrypt --db app --old-key-file /etc/bakelite/key-old.txt

See Configuration → Toggling or rotating for the full enable / rotate / decrypt workflows.

doctor

Pre-flight checks (no daemon, no writes): run before daemon to catch the misconfigurations that today only surface mid-replication. Backend-only.

bakelite doctor --config bakelite.toml          # every database
bakelite doctor --db app --config bakelite.toml # one database
bakelite doctor --json --config bakelite.toml

For each database it verifies:

Exits non-zero on any failure. Drops straight into a CI step before deployment.