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 cron —
Operations 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:
-
User=/Group=— run bakelite as the account that owns your databases. This is the one setting you can't skip. bakelite co-owns each database: it drives checkpoints (rewriting the DB file and its-wal/-shmsidecars) and writes a.bakelite/state dir beside the file — so it needs read+write on the database and its directory. That's almost always your application's own user. The package shipsUser=bakeliteas a placeholder and creates that system user, but a freshbakeliteuser can't read your app's database, so enabling the service without changing this will fail to start (you'll see a permission error in the journal). Set it to your app's user and you're done.Prefer a dedicated user over running as the app? Add
bakeliteto the app's group (sudo usermod -aG <appgroup> bakelite), make the database directory group-writable (g+wsso new-wal/.bakelitefiles inherit the group) and the database file group-read+write, and ensure the app writes them with that group. It's more moving parts than just using the app's user — most people don't bother. -
ReadWritePaths=— list every database directory and (for afilebackend) the backup directory.
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.