Recipes
A recipe is a curated, opinionated starting point for a common setup. The
bakelite init wizard is built around them: pick a recipe,
answer a few questions, and it writes a validated config — so you never start from
a blank file or copy-paste the wrong backend form.
Recipes are files on disk. bakelite init
discovers them, in precedence order:
$BAKELITE_RECIPES_DIR— an explicit override (dev, tests, custom installs)/etc/bakelite/recipes/— site/admin recipes (add your own, or override a built-in by dropping a<name>.recipewith the same name)/usr/share/bakelite/recipes/— the built-ins the package ships
So --recipe <name> works for your recipes too — drop myorg.recipe into
/etc/bakelite/recipes/ and run bakelite init --recipe myorg; it also shows up in
the wizard's picker. (The package pre-creates that directory with a README
explaining the format.) Each built-in is also shipped as a plain <name>.toml config
in /usr/share/bakelite/recipes/, so you can read or copy one directly. (Because
recipes live on disk, a binary without the package layout has none — install the
package, set BAKELITE_RECIPES_DIR, or use --recipe-file.)
Using the wizard
On a terminal:
sudo bakelite init
It walks you through the recipe pick, the database paths, the backend details,
optional at-rest encryption, the systemd User=/ReadWritePaths= (it reads the
database's owner to propose the right User=), and then runs
doctor. Each step is confirmed and nothing is overwritten
without asking, so it's safe to re-run.
Non-interactive (CI / unattended): scaffold a recipe's starter file without prompting, then edit it.
sudo bakelite init --recipe s3 --yes # writes /etc/bakelite/bakelite.toml
The built-in recipes are local, s3, s3-local, encrypted-worm, multi-db,
and sftp — plus any you've added under /etc/bakelite/recipes/ (see
below).
The recipes
local — local disk only
The simplest setup: replicate to a local or mounted directory. No credentials. Good for a single machine, a mounted NAS, or trying bakelite out.
[[backends]]
type = "file"
path = "/var/backups/bakelite"
[[database]]
name = "app"
path = "/var/lib/app/app.db"s3 — single S3 bucket
One S3-compatible bucket: AWS S3, Cloudflare R2, Backblaze B2, or MinIO.
Credentials go in /etc/bakelite/bakelite.env (chmod 600), never in the config.
For a non-AWS service set endpoint and a matching region (path-style addressing
is the default whenever an endpoint is set).
[[backends]]
type = "s3"
bucket = "my-bucket"
prefix = "bakelite"
# endpoint = "https://ACCOUNT.r2.cloudflarestorage.com"
region = "us-east-1"
[[database]]
name = "app"
path = "/var/lib/app/app.db"s3-local — S3 + local redundancy
A fast local mirror on the hot path (instant restore) plus an S3 copy
reconciled in the background (mode = "async") for off-site disaster recovery. A
slow or down S3 never paces live replication. See
Multi-destination replication.
[[backends]]
type = "file"
path = "/var/backups/bakelite"
[[backends]]
type = "s3"
bucket = "my-bucket"
prefix = "bakelite"
region = "us-east-1"
mode = "async"
[[database]]
name = "app"
path = "/var/lib/app/app.db"encrypted-worm — encrypted + immutable (WORM)
At-rest client-side encryption plus S3 Object Lock, so backups can't be
deleted or overwritten until their retention expires — even by stolen credentials.
Because Object Lock denies deletes, retention and compaction are disabled. You must
create the bucket with Object Lock enabled (and a default retention policy)
yourself — bakelite never sets it. Generate the key first with
bakelite keygen and keep an off-host copy; lose it and
the backups are unrecoverable. See
Immutable backups with Object Lock
and At-rest encryption.
[defaults]
retention = "0s" # Object Lock denies deletes — don't try to prune
compaction_levels = [] # …and don't rewrite/merge objects either
[defaults.encryption]
key_file = "/etc/bakelite/key.txt"
[[backends]]
type = "s3"
bucket = "my-locked-bucket"
prefix = "bakelite"
region = "us-east-1"
expect_object_lock = true # doctor fails if the bucket isn't locked
checksum = true # required: Object Lock rejects un-checksummed uploads
[[database]]
name = "app"
path = "/var/lib/app/app.db"multi-db — many databases, one shared backend
Configure the destination once as a shared top-level [[backends]] and list
each database with just a name and path. Objects are namespaced by database name,
so one bucket or directory serves them all. See
Shared backend for many databases.
[[backends]]
type = "file"
path = "/var/backups/bakelite"
[[database]]
name = "user1"
path = "/var/lib/app/users/1/data.db"
[[database]]
name = "user2"
path = "/var/lib/app/users/2/data.db"sftp — SFTP offsite (over SSH)
Replicate to a remote directory over SFTP. The transport is pure-Rust (no system
ssh binary). Authenticate with an SSH key (recommended) or a password sourced
from bakelite.env. Add the server's host key to known_hosts first. See
SFTP.
[[backends]]
type = "sftp"
host = "backup.example.com"
user = "bakelite"
path = "/srv/backups/bakelite"
identity_file = "/etc/bakelite/.ssh/id_ed25519"
[[database]]
name = "app"
path = "/var/lib/app/app.db"Your own recipe file
Got a house-style setup you want to reuse across machines? Point init at a local
file instead of a built-in recipe:
sudo bakelite init --recipe-file ./my-recipe.toml # a plain config, or a recipe
--recipe-file accepts either form:
- A plain
bakelite.tomlis validated through the real config parser (so a malformed file is rejected before it lands at--config) and written verbatim. - A recipe (a file with
+++frontmatter — see below) runs the same guided wizard the built-ins use: it prompts, renders, and offers the key/credentials/ systemd steps. Because an external recipe's config decides where your data and credentials go, it runs behind a single up-front consent prompt (default No) after showing you the full generated config. Under--yes(or with no terminal) it scaffolds with the recipe's defaults and performs no host changes.
Either way, review the result and run doctor after; add
--force to overwrite an existing config. This is deliberately local-file
only — there is no fetch-from-URL. Validation proves a config is well-formed,
not that its destinations are benign, and in a backup tool the config is the
security boundary. Use a file you control.
Authoring a recipe
A recipe is a single file (<name>.recipe): a +++-delimited TOML manifest
(what to ask) followed by a minijinja body (the
config to render). The built-ins in /usr/share/bakelite/recipes/ are exactly this
format — copy one into /etc/bakelite/recipes/ as a starting point. The rendered
output is always validated by the config parser before anything is written.
+++
id = "s3"
title = "Single S3 bucket"
summary = "One S3-compatible bucket."
[[prompt]]
var = "bucket"
ask = "S3 bucket"
required = true # re-prompt until non-blank
scaffold = "my-bucket" # placeholder used for the non-interactive scaffold
[[prompt]]
var = "endpoint"
ask = "Endpoint URL — blank for AWS S3"
allow_blank = true # an empty answer is allowed (lets the body omit the line)
[[prompt]]
var = "region"
ask = "Region"
# A conditional default — the only ones allowed, a small closed set:
default_if_blank = { var = "endpoint", then = "us-east-1", else = "auto" }
# also: default = "lit" | default_from = { base = "config_dir", join = "key.txt" }
[loop] # collect one-or-more databases…
add_another = "Add another database?" # …omit this key for a single database
[[loop.field]]
var = "name"
ask = "Database name"
default_first = "app"
default_rest = "db{n}" # {n} = the 1-based iteration index
[[loop.field]]
var = "path"
ask = "Path to {name}'s SQLite file" # {field} interpolates an earlier answer
required = true
[[subflow]] # an optional yes/no block
flag = "encryption"
ask = "Encrypt backups at rest?"
default = false
[[subflow.prompt]]
var = "key_file"
ask = "Encryption key file"
default = "/etc/bakelite/key.txt"
[[warning]]
text = "Password auth: set BAKELITE_SFTP_PASSWORD in bakelite.env."
when_blank = "identity_file" # shown only when that answer is blank
+++
[[backends]]
type = "s3"
bucket = {{ bucket | toml }} # | toml escapes the value
{% if endpoint %}endpoint = {{ endpoint | toml }}
{% endif %}region = {{ region | toml }}
{% for db in databases %}
[[database]]
name = {{ db.name | toml }}
path = {{ db.path | toml }}
{% endfor %}
Notes:
- The body is standard minijinja;
{{ var | toml }}is the convention for every value that becomes a TOML string. Rendering usestrim_blocks+lstrip_blocks, so a{% %}tag on its own line leaves no blank line. - You don't declare side-effects. The wizard derives them from the rendered
config: an S3 backend ⇒ AWS credentials, an SFTP backend without
identity_file⇒ a password, file backends + database dirs ⇒ systemdReadWritePaths, an[encryption].key_filethat's missing ⇒ offer to generate it. - A built-in recipe's plain
<id>.toml(shipped to/usr/share/bakelite/recipes/and shown above) is generated from its.recipesource byjust recipes-regen; a test fails if the two drift.
Beyond the recipes
Recipes cover the common shapes; every knob is in
Configuration — per-database overrides, GCS and Azure
backends, server-side encryption, cold storage classes, retention and compaction
tuning, and more. bakelite init writes an ordinary config you can keep editing.