Running BurzBlog
BurzBlog is one binary and one SQLite file. These steps run it under systemd on a Raspberry Pi or any Linux host, behind HTTPS.
1. Get the binary
Build it, or cross-compile for the Pi from another machine:
task build # bin/burzblog for this machine
task release # dist/ for linux/arm64, linux/arm (Pi 2/3 32-bit), amd64, macOS, Windows
Copy the right file to the host and name it burzblog. A 64-bit Raspberry Pi OS
uses linux-arm64.
2. Install the service
sudo useradd --system --home-dir /var/lib/burzblog --shell /usr/sbin/nologin burzblog
sudo install -m 0755 burzblog /usr/local/bin/burzblog
sudo install -m 0644 deploy/burzblog.service /etc/systemd/system/burzblog.service
sudo systemctl edit burzblog
In the override, set the public address people will open:
[Service]
Environment=BURZBLOG_ORIGIN=https://blog.example.com
The origin must match the address in the browser exactly (scheme, host and port). Browser requests from anywhere else are refused.
To rebuild your website after publishing, put the deploy hook URL in a private file, because it is a secret:
sudo install -d -m 0755 /etc/burzblog
echo 'BURZBLOG_DEPLOY_HOOK=https://api.vercel.com/v1/integrations/deploy/...' | sudo tee /etc/burzblog/env >/dev/null
sudo chmod 0600 /etc/burzblog/env
Start it and create the first account:
sudo systemctl enable --now burzblog
journalctl -u burzblog -n 20
While the server has no accounts, it logs a one-time setup link on every start:
msg="no accounts yet; open https://blog.example.com/setup#token=... to create the first one (valid for 60 minutes; restart to get a new link), or run: burzblog user add <name> --data /var/lib/burzblog"
Open that link in your browser, choose a username and a password of at least 12
characters, and you are signed in. The link works once and for 60 minutes; after
that, or once any account exists, it is dead and sudo systemctl restart burzblog prints a new one if it is still needed. The token sits after the #,
so browsers never send it to your reverse proxy, and the panel removes it from
the address bar as soon as the page opens. Anyone who can read the service log
can use the link, so open it before you share the logs.
Prefer the terminal? Create the account on the host instead; that also ends first-run setup:
sudo -u burzblog burzblog user add ada --data /var/lib/burzblog
Later accounts are managed on the host only: burzblog user add, user passwd,
user role and user list, always with --data /var/lib/burzblog and as the
burzblog user. Passwords need at least 12 characters.
user passwd also signs that account out of every browser.
An account is an admin or an author. The account first-run setup creates is an
admin, and so is the first account user add makes; later ones are authors
unless you add --role admin. An author
writes and publishes posts and manages their own password, two-factor sign-in
and tokens; an admin can also delete posts, manage everyone's tokens, change
settings, deploy, download backups and read the audit log (see
security for the full list). Change an account's role with
burzblog user role <name> admin|author --data /var/lib/burzblog; the last
admin cannot be demoted. burzblog audit --data /var/lib/burzblog prints the
audit log on the host.
If someone loses their authenticator app and recovery codes,
burzblog user 2fa-reset <name> --data /var/lib/burzblog turns two-factor
sign-in off for them and signs them out of every browser. Their terminal logins
keep working; they can set two-factor up again in Settings.
3. HTTPS in front
BurzBlog listens on 127.0.0.1:8080 and does not terminate TLS. Put a reverse
proxy in front:
- deploy/Caddyfile: Caddy obtains certificates itself.
- deploy/nginx.conf: nginx with your certificates.
Both forward the original Host header and limit request bodies to 4 MB.
task proxy:lint validates both files with pinned images.
On a home network without a public address, a tunnel (for example Cloudflare
Tunnel) to 127.0.0.1:8080 works the same way; set BURZBLOG_ORIGIN to the
tunnel's public HTTPS address. Your website's build must be able to reach this
address to download posts.
Settings
| Flag | Environment | Default |
|---|---|---|
--addr |
BURZBLOG_ADDR |
127.0.0.1:8080 |
--data |
BURZBLOG_DATA, then systemd's STATE_DIRECTORY |
./burzblog-data |
--origin |
BURZBLOG_ORIGIN |
http://<addr> |
--deploy-hook |
BURZBLOG_DEPLOY_HOOK |
none |
--check-updates |
BURZBLOG_CHECK_UPDATES (true or false) |
off |
--site-url |
BURZBLOG_SITE_URL |
none (feeds and sitemap answer 409) |
--site-title |
BURZBLOG_SITE_TITLE |
the site URL's host |
--site-description |
BURZBLOG_SITE_DESCRIPTION |
the title |
--site-language |
BURZBLOG_SITE_LANGUAGE |
en |
--post-path |
BURZBLOG_POST_PATH |
/blog/{slug} |
--tag-path |
BURZBLOG_TAG_PATH |
none (no tag pages in the sitemap) |
Prefer the environment for the deploy hook so it does not show up in process listings.
Update checks
BurzBlog contacts nobody unless you ask. With --check-updates (or
BURZBLOG_CHECK_UPDATES=true) it looks for a newer release about 30 seconds
after it starts and then every 24 hours. Admins see the result in Settings and
as a dot on the Settings tab; authors do not, and the content API never
includes it.
Each check is one GET to
https://api.github.com/repos/weburz/burzblog/releases/latest. The only thing
it carries is a User-Agent such as burzblog/1.2.0, which holds your BurzBlog
version. Nothing about your posts, accounts or the instance's address is sent,
though GitHub sees your server's IP address as it would for any request. The
check has a 10 second timeout, reads at most 1 MiB, is not retried before the
next day and keeps its answer in memory only. If it fails, BurzBlog logs one
warning and carries on. A development build (a version that is not
MAJOR.MINOR.PATCH) never reports an update. Remove the flag and restart to
stop the checks.
When a newer version exists, upgrade with sudo burzblog upgrade and restart
the service on a systemd host, or pull the new image and recreate the
container.
The --site-*, --post-path and --tag-path settings describe your public
website (not BurzBlog's own address, --origin). They only build the links in
the feeds and sitemap. --site-url is an address without a path, such as
https://example.com; the paths must start with / and hold {slug} or
{tag} once. They are not secret. BurzBlog refuses to start when one is
invalid. See publishing.
Backups
Everything a server holds is in its data directory: burzblog.db (posts,
every earlier version of every post, accounts with their password hashes,
two-factor secrets and roles, token digests, tag labels and the audit log) and
the media folder with the uploads. A disk that fails takes all of it with it,
so keep a copy on another machine or in storage elsewhere.
Full snapshots
burzblog snapshot create writes everything to one file while BurzBlog keeps
running: the database is copied consistently (SQLite VACUUM INTO), then every
upload it records is added, with a manifest of sizes and SHA-256 checksums.
It never upgrades the database, so a newly installed binary can snapshot a
server that has not restarted onto it yet. Run it as the service user:
sudo -u burzblog burzblog snapshot create --data /var/lib/burzblog
# ✓ Wrote /var/lib/burzblog/snapshots/burzblog-snapshot-20261027T031700.000Z.tar.gz (4.2 MB).
Without a target it writes to the snapshots folder in the data directory. A
target ending in .gz or .enc is the file to write; anything else is a
folder, made when missing. - writes to standard output. --keep N deletes
all but the newest N snapshots in the folder and touches no other file. An
upload the database records but the media folder lacks is reported and
skipped, so one lost file does not stop backups. Each snapshot is recorded in
the audit log, and admins see the newest one under Settings → Backup, which
warns when there is none or it is more than a week old.
A snapshot is a plain .tar.gz you can open with tar, holding password
hashes and two-factor secrets. Encrypt it when it leaves the server: put a
passphrase of at least 12 characters in BURZBLOG_SNAPSHOT_PASSPHRASE or a
file passed with --passphrase-file (or type one with --encrypt), and the
snapshot is sealed with it and named .tar.gz.enc (see
security). Encryption also proves on restore that
nobody changed the file; a plain snapshot has checksums against damage, not
against someone who can edit it. Keep the passphrase somewhere other than
the server: without it nobody can restore the snapshot, including you.
Make one every night with a systemd timer, keeping two weeks, encrypted:
sudo install -d -o burzblog -g burzblog -m 0700 /var/backups/burzblog
sudo install -m 0600 -o burzblog -g burzblog /dev/null /etc/burzblog/snapshot-passphrase
sudo -e /etc/burzblog/snapshot-passphrase # one line: the passphrase
# /etc/systemd/system/burzblog-snapshot.service
[Unit]
Description=BurzBlog snapshot
[Service]
Type=oneshot
User=burzblog
ExecStart=/usr/local/bin/burzblog snapshot create /var/backups/burzblog --keep 14 \
--data /var/lib/burzblog --passphrase-file /etc/burzblog/snapshot-passphrase
# /etc/systemd/system/burzblog-snapshot.timer
[Unit]
Description=Nightly BurzBlog snapshot
[Timer]
OnCalendar=*-*-* 03:17:00
Persistent=true
[Install]
WantedBy=timers.target
sudo systemctl daemon-reload
sudo systemctl enable --now burzblog-snapshot.timer
sudo systemctl start burzblog-snapshot.service # one now, to check it works
That folder is still on the same disk. Copy it off with the tool you already
use (rclone, restic, rsync), or pull snapshots from another machine
instead, which needs nothing extra on the server:
ssh blog.example.com 'sudo -u burzblog burzblog snapshot create - --data /var/lib/burzblog' > blog-$(date +%F).tar.gz
Check a copy now and then: burzblog snapshot verify <file> reads the whole
snapshot, checks every checksum and the database's integrity, and reports what
it holds, without restoring anything. It unpacks into the system's temporary
folder (or --work <folder>), which needs room for the whole snapshot.
Restoring
On the new server, install BurzBlog as above, then restore before or instead of the first start, as the service user:
sudo systemctl stop burzblog
sudo -u burzblog burzblog snapshot restore blog.tar.gz.enc --data /var/lib/burzblog \
--passphrase-file /etc/burzblog/snapshot-passphrase
sudo systemctl start burzblog
Restore refuses while a server runs with the data directory. The whole
snapshot is checked before anything changes, so a damaged one leaves the
directory as it was. A directory that already holds a blog is refused; with
--force its database and uploads are moved to
<data>/backups/replaced-<UTC time>/, not deleted. Run as root, it gives the
restored files to the owner of the data directory. Accounts, sessions,
two-factor and tokens come back as they were, uploads keep their addresses,
and a snapshot from an older BurzBlog is upgraded during the restore (with a
backup first). Keep the same public address (BURZBLOG_ORIGIN): posts link
to their uploads by full address.
Other copies
Settings → Backup → Download all posts and burzblog backup [dir] give a
plain-text copy of every current post, drafts and archived ones included, as
<status>/<slug>.md files with a README.txt and the uploads in media/.
Restore a post with burzblog push. They hold current posts only: earlier
versions, accounts and tokens are only in a snapshot. burzblog export --out dir writes every published post as a Markdown file.
Without BurzBlog at hand, a stopped server's data directory is also a complete
copy: burzblog.db (the -wal and -shm files are folded in on a clean stop)
and media/ (cp -a keeps the owner and modes 0600/0700).
Uploads default to 10 MiB each (--max-upload-mb, BURZBLOG_MAX_UPLOAD_MB);
raise the request-size limit of your reverse proxy to match (the example
configs allow 12 MiB).
Upgrades
On a binary or systemd install, burzblog upgrade replaces the binary with the
latest release:
burzblog upgrade --check # exit 0 up to date, 7 update available
sudo burzblog upgrade # download, verify, replace
sudo systemctl restart burzblog
It downloads burzblog-<version>-<os>-<arch> and SHA256SUMS-<version> from
the GitHub release over https only (at most 200 MiB), refuses a file whose
SHA-256 sum differs from the list, and swaps the binary in with a rename in the
same directory, so a failure leaves the old binary in place. It writes to the
directory of the running binary: if that is not writable it says so and
suggests sudo. It never restarts the service and never runs what it
downloaded. --version vX.Y.Z picks a release, --force allows a downgrade or
a reinstall, and a development build (dev) only upgrades with --version.
You can also replace the binary by hand (sudo install -m 0755 burzblog /usr/local/bin/burzblog).
Containers are upgraded by pulling the new image
(docker compose pull && docker compose up -d); burzblog upgrade refuses to
run when it finds /.dockerenv, /run/.containerenv, or BURZBLOG_DATA=/data
with BurzBlog as process 1.
The database upgrades itself, with a backup first
The schema is upgraded when the new binary starts. If the database is older
than the binary, BurzBlog first writes a consistent copy of it (SQLite
VACUUM INTO, safe while the file is in use) to
<data>/backups/burzblog-schema<N>-<UTC time>.db
where <N> is the schema it had, logs the path, and then migrates. If the copy
cannot be made (disk full, permissions on the data directory) it stops with an
error and does not touch the database. A new database and one already current
get no copy. The newest five of these files are kept and older ones are
deleted; nothing else in backups/ is touched. The folder is 0700 and the
files 0600. burzblog backup and Settings → Backup do not include it;
if you back up the data directory itself, it is included.
An older binary refuses to open a database written by a newer one, rather than damaging it. To go back to the older version after an upgrade:
- Stop the service:
sudo systemctl stop burzblog. - Delete
burzblog.db-walandburzblog.db-shmnext to the database. - Copy the backup over the database:
sudo -u burzblog cp /var/lib/burzblog/backups/burzblog-schema<N>-<time>.db /var/lib/burzblog/burzblog.db(mode0600, owned byburzblog). Anything written since the upgrade is lost. - Install the older binary and start the service.
Uploads in media/ are not touched by migrations, so they need no restoring.
Take your own backup (see above) before an upgrade as well; the automatic copy
sits on the same disk.
burzblog upgrade calls the GitHub API and downloads release files without
credentials, so it works only while the releases are publicly downloadable. Its
tests run against local test servers.