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

Install & deploy

bakelite ships as a single fully-static Linux binary (~9 MB, no runtime dependencies) for amd64 and arm64, distributed as a signed apt repository, a .deb/.tar.gz per release, and a container image. On macOS it installs via Homebrew as a universal (Apple Silicon + Intel) binary — see macOS (Homebrew).

Quick install

curl -fsSL https://bakelite.duggan.ie/install.sh | sudo sh

On Debian/Ubuntu this adds the signed apt repository and installs the package; on other Linux it installs the static binary tarball. Either way you get the binary, an example config at /etc/bakelite/bakelite.toml, and a systemd unit (installed but not enabled — you finish configuring it below). Run bakelite --version and you should get a version line back. On macOS the same script detects Darwin and routes to Homebrew (see macOS (Homebrew)) — run it without sudo, since Homebrew won't run as root.

Track bleeding-edge builds of main instead of stable releases:

curl -fsSL https://bakelite.duggan.ie/install.sh | BAKELITE_CHANNEL=dev sudo -E sh

To uninstall: curl -fsSL https://bakelite.duggan.ie/uninstall.sh | sudo sh.

Guided setup

When you run the installer on a terminal it offers to launch the setup wizard right away — or run it yourself any time:

sudo bakelite init

bakelite init walks you through picking a recipe, writes a validated config, optionally generates an at-rest encryption key and a chmod 600 credentials file, sets the systemd unit's User=/ReadWritePaths= for you (the two things that otherwise trip people up — it reads the database file's owner to get User= right), and finishes by running doctor to confirm it all works. It's safe to re-run; every step is confirmed and nothing is overwritten without asking.

For CI or unattended installs there's a non-interactive path — scaffold a recipe's starter config without prompting (the installer also never prompts when piped or when BAKELITE_NONINTERACTIVE=1 is set):

sudo bakelite init --recipe s3 --yes      # writes /etc/bakelite/bakelite.toml to edit

The Recipes page lists the available scenarios. Prefer to wire everything up by hand? The Configure & run section below is the manual equivalent.

apt repository (manual)

If you'd rather add the repo yourself instead of piping a script:

curl -fsSL https://bakelite.duggan.ie/gpg | sudo gpg --dearmor -o /usr/share/keyrings/bakelite.gpg
echo "deb [signed-by=/usr/share/keyrings/bakelite.gpg] https://bakelite.duggan.ie/apt stable main" \
  | sudo tee /etc/apt/sources.list.d/bakelite.list
sudo apt-get update && sudo apt-get install bakelite

Pin an exact version with sudo apt-get install bakelite=0.1.0. Use the dev suite in the source line for bleeding-edge builds.

Container image

Multi-arch images are published to GitHub Container Registry:

docker run -d --name bakelite \
  -v /var/lib/myapp:/var/lib/myapp \
  -v /etc/bakelite:/etc/bakelite:ro \
  ghcr.io/duggan/bakelite:latest \
  daemon --config /etc/bakelite/bakelite.toml

The container must read and write each database's directory (bakelite writes the -wal/-shm sidecars and a .bakelite/ cursor dir beside the DB), so run it as the user that owns those files (--user) and mount their directories. The image runs as a non-root user by default, so a Permission denied at .bakelite/ almost always means the container's user doesn't match the files' owner.

Sharing a volume with the app (the usual setup — backing up a database another container writes): the volume is owned by whatever user that app runs as, so run bakelite as the same user. If the app runs as root, that's user: "0:0":

services:
  app:
    # … writes /data/app.db …
    volumes: [data:/data]
  bakelite:
    image: ghcr.io/duggan/bakelite:edge
    user: "0:0"                       # match the volume owner (root, here)
    volumes:
      - data:/data
      - ./bakelite.toml:/etc/bakelite/bakelite.toml
    command: daemon
volumes:
  data:

Not sure who owns it? ls -lan the volume's files (or docker compose exec app id) and set user: to match.

Tags: :0.1.0 (exact), :0.1 (patches), :latest (newest stable), :edge (latest main).

Tarball / GitHub Releases

Every release attaches .deb and .tar.gz for amd64 and arm64 to its GitHub Release — handy for air-gapped installs or baking into an image. macOS releases also attach a bakelite-<ver>-darwin-universal.tar.gz (the binary Homebrew downloads).

macOS (Homebrew)

On macOS bakelite installs through Homebrew — a universal (Apple Silicon + Intel), Developer-ID-signed and notarized binary, with launchd supervision through brew services:

brew install duggan/tap/bakelite

The one-line installer at the top also detects macOS and routes here, so curl … | sh works too — but run it without sudo, since Homebrew refuses to run as root.

Configure. Unlike Linux, the config lives under the Homebrew prefix, not /etc/bakelite. Point the wizard at that path (or export BAKELITE_CONFIG so ad-hoc commands find it too):

bakelite init --config "$(brew --prefix)/etc/bakelite/bakelite.toml"
export BAKELITE_CONFIG="$(brew --prefix)/etc/bakelite/bakelite.toml"

bakelite must run as the owner of the databases it backs up — it checkpoints them and writes a .bakelite/ dir beside each — so the account brew services runs it under (yours) needs read+write on the database and its directory. A daemon reading databases in protected locations (e.g. ~/Documents) may also need Full Disk Access (System Settings → Privacy & Security).

Run as a service. brew services generates the launchd job from the formula:

brew services start bakelite        # start now and at login
brew services info bakelite         # status
tail -f "$(brew --prefix)/var/log/bakelite.log"

Installed manually, or prefer not to use brew services? Copy the launchd plist template dist/ie.duggan.bakelite.plist into ~/Library/LaunchAgents/, edit its --config (and binary) path, then:

launchctl bootstrap gui/$(id -u) ~/Library/LaunchAgents/ie.duggan.bakelite.plist
launchctl enable gui/$(id -u)/ie.duggan.bakelite

Scheduled verification. macOS has no systemd timers; schedule the verify/health-check commands (see below) with launchd StartCalendarInterval agents or cronOperations has the details. The systemd-specific Configure & run section that follows is for Linux; on macOS the steps above replace it.

Configure & run

bakelite init (see Guided setup) automates everything in this section. To do it by hand instead, point bakelite at your databases and a backend:

sudoedit /etc/bakelite/bakelite.toml        # set each [[database]].path + the backend
# S3 creds. The apt/.deb package ships the example at the path below; the static
# tarball doesn't — just create /etc/bakelite/bakelite.env yourself.
sudo cp /usr/share/bakelite/bakelite.env.example /etc/bakelite/bakelite.env
sudo chmod 600 /etc/bakelite/bakelite.env   # (skip for file backends)

bakelite reads /etc/bakelite/bakelite.env itself at startup (not just under systemd), so the same file covers the daemon and interactive CLI use. See Environment variables for the full set and load order.

See Configuration for every option. Then edit the unit (sudo systemctl edit --full bakelite) for your environment. The systemd bit is the one slightly fiddly part, and it's only fiddly because of file permissions. Two things to set:

sudo systemctl enable --now bakelite
journalctl -u bakelite -f          # watch it bootstrap + back up

Tip: confirm the access before enabling, as the user the daemon will run as: sudo -u <that-user> bakelite doctor --db <name>. Its database access check reports whether that user can read the database and write its directory.

Scheduled verification & health checks

The daemon backs up continuously; to be paged when a backup stops or goes stale — and to exercise an actual restore on a schedule, so you find out a replica is unrestorable before you need it — the package also ships three timer units, installed disabled just like the daemon. Enable the ones you want:

sudo systemctl enable --now bakelite-check.timer        # liveness + RPO + verify-freshness, ~5 min
sudo systemctl enable --now bakelite-verify.timer       # daily integrity check
sudo systemctl enable --now bakelite-verify-deep.timer  # weekly full restore drill

What to alert on, the cron alternative for non-systemd hosts, and a step-by-step disaster-recovery runbook are all in Operations & disaster recovery.

Verify

bakelite status --config /etc/bakelite/bakelite.toml          # what the daemon is doing now
bakelite list   --config /etc/bakelite/bakelite.toml          # configured databases
bakelite list   --config /etc/bakelite/bakelite.toml --db app # that database's restore points

A healthy daemon is silent — it wakes only when the WAL changes. bakelite status is the quickest health check and flags a daemon that has died or gone stale.

Build from source

For contributors or an unsupported target, build the static binary yourself (needs Docker) and install it manually:

./scripts/build-linux.sh
# -> target/x86_64-unknown-linux-musl/release/bakelite  (static, stripped, ~9 MB)
scp target/x86_64-unknown-linux-musl/release/bakelite USER@HOST:/tmp/bakelite
ssh USER@HOST 'sudo install -m 0755 /tmp/bakelite /usr/local/bin/bakelite && bakelite --version'

Then create /etc/bakelite/{bakelite.toml,bakelite.env} and install dist/bakelite.service as in Configure & run above. Deployment templates live in dist/.

On macOS there's no Docker step — bakelite builds natively:

cargo build --release -p bakelite
install -m 0755 target/release/bakelite /usr/local/bin/bakelite   # or your $PATH

Then follow macOS (Homebrew) for the config path and the launchd plist (skip the brew install step — you already have the binary).

Memory & large databases

Snapshots and restores stream the database page-by-page through the compressor, so base memory stays bounded (~tens of MB) regardless of database size — a single-threaded snapshot of a 2 GB database runs at ~10 MB RSS.

bakelite runs continuously in the background, so the defaults favour a low, steady footprint over a fast bootstrap: snapshots compress single-threaded and run one at a time across all your databases. Single-threaded, one-at-a-time compression keeps CPU and memory low and even, rather than the periodic spikes a parallel snapshot would add on a host that's also running your application.

When you'd rather trade memory for a faster bootstrap, both knobs open up. Snapshot compression can go multithreaded (zstd workers): a 2 GB snapshot drops from ~25 s to ~6 s on 16 cores, but peak RSS climbs to ~285 MB (the per-worker compression buffers, versus ~10 MB single-threaded). Set --snapshot-workers <n> to pick the worker count (0 = auto = single-threaded; 1 is the same; raise it to speed up one very large database).

--max-concurrent-snapshots caps how many databases snapshot at once. It defaults to 1 (serialised) for that steady footprint; since snapshots stream, this bounds concurrent compression/IO, not memory. Raising it makes a many-database bootstrap much faster (≈3× for 50 medium DBs in testing) at the cost of a sharper CPU/IO spike:

ExecStart=/usr/bin/bakelite daemon --config /etc/bakelite/bakelite.toml --max-concurrent-snapshots 4

One edge to know: the first bootstrap reads the database's existing WAL into memory (bounded by the WAL's size). Once bakelite is running it keeps the WAL under max_wal_size, so this only matters if you start it against a database with a very large pre-existing WAL.