# bokf — running in Docker Status: Draft 0.1 · 2026-09-17 · License: GPL-3.0-or-later Two roles: - **host** — runs Docker + the compose plugin only. No toolchain, no registry. - **dev machine** — has the source and builds the image; deploys with `scripts/deploy.sh` over SSH. There is no forge or registry in this flow. Releases are git tags, images are transferred directly with `docker save | ssh docker load`. State lives in two bind mounts next to `compose.yaml`: | Host path | Container | Contents | |---|---|---| | `var/db` | `/var/lib/bokfd` | SQLite database, `backup/`, `export/`, `certs/` | | `var/run` | `/run/bokfd` | Unix socket (mode 0660, owned by uid 10001) | The `bokfd` service publishes port 8788 for the TLS listener; the `certs` sidecar renews the certificate with lego via INWX DNS-01. Plain TCP stays off (loopback-only if enabled); the Unix socket is for clients on the host. The database is a single SQLite file. Back up with `backup.snapshot` (`VACUUM INTO`) and point restic at `var/db/backup` — never at the live file. ## First run On the dev machine, put the SSH target in `.env`: ```sh cp .env.example .env # set BOKF_HOST=user@host and BOKF_REMOTE_DIR=/srv/bokf scripts/deploy.sh v0.1.0 ``` The first run stops after shipping the image and compose.yaml, and prints the one-time init command. Run it on the host: ```sh cd /srv/bokf docker compose run --rm -e BOKFD_PASSWORD='' bokfd init docker compose up -d docker compose ps # wait for "healthy" ``` `init` creates the admin user and must run before the first `up`; it refuses to touch an already initialized database. Subsequent `scripts/deploy.sh` runs update the image, tag and compose file, restart the daemon and wait for the healthcheck. ## Fast iteration (`--dev`) Normal deploys build an image and recreate the container. While developing, `scripts/deploy.sh --dev` skips the image entirely: - the gate (`make` + `make test`) still runs locally, - on an architecture mismatch the static aarch64 binaries are cross-compiled here (about 20 s, `deploy/Dockerfile.cross`), - the binaries are copied into the running container with `docker cp`, - the daemon is reloaded with `SIGHUP`, which re-execs the binary in place (in-memory sessions are lost, clients reconnect), - the version the daemon reports is verified against the tag. No image is built and the container is not recreated; a later normal deploy replaces the copied binaries. Use a descriptive tag, e.g. `scripts/deploy.sh --dev v0.2.0-rc1`. ## Deploying upgrades ```sh git tag v0.1.1 scripts/deploy.sh # tag defaults to git describe scripts/deploy.sh v0.1.1 # or pass one explicitly ``` The script: 1. `make` + `make test` on the dev machine, 2. builds `bokf:`: locally and ships it with `docker save | gzip | ssh docker load`, or — when the host runs a different CPU architecture — cross-compiles the backend here and assembles the image on the host from a source tar, 3. copies `compose.yaml` and writes `BOKF_IMAGE`/`BOKF_TAG` into the host's `.env` (other keys are preserved), 4. `docker compose up -d --no-build`, then polls the container healthcheck, 5. on failure, puts the previous `BOKF_TAG` back and rolls back to the image that is still loaded on the host. Architecture mismatches are automatic: `uname -m` is compared over SSH and a mismatch runs `deploy/Dockerfile.cross`, which links `bokfd`/`bokfctl` statically for aarch64 (glibc + OpenSSL archives; it runs on the Alpine runtime directly, DNS included). The image is then assembled in the host's Docker — no compilation there. Override with `BOKF_BUILD=local` or `BOKF_BUILD=remote` (also settable in `.env`). The runtime image is Alpine, carries `bokfd` + `bokfctl` only (no `libssl3`; the binaries are static) and never ships the ncurses TUI, which is a frontend built on the client. Tags are `git describe` output unless passed. Tag releases (`v*`) so rollback and support have meaningful versions. The rollback image must still exist on the host; don't prune before the new version has proven itself. Manual rollback — set `BOKF_TAG` on the host and restart: ```sh cd /srv/bokf sed -i 's/^BOKF_TAG=.*/BOKF_TAG=v0.1.0/' .env docker compose up -d --no-build ``` ## Optional: a bare repository on the host For an off-machine copy of the source and an optional auto-deploy hook: ```sh ssh host 'git init --bare /srv/git/bokf.git' git remote add host ssh://host/srv/git/bokf.git git push host main ``` If the host also has the toolchain and a checkout whose origin is that bare repo, `deploy/post-receive.sample` can build, test and restart on every push to `main`. The normal `scripts/deploy.sh` flow does not need any of this. ## Clients The image ships only the daemon and `bokfctl`, which covers the healthcheck and quick admin calls: ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin fiscal_year.list ``` The TUI (`bokftui`) is a frontend: build it from source on the machine you sit at (`make bokftui`, needs libncursesw) and point it at the host, normally the TLS listener. A host-installed client can also point at `var/run/bokfd.sock`, but that file is owned by uid 10001, so the host user must be in that group (or use `sudo`). The TUI remembers the server and user (never the password) in `$XDG_CONFIG_HOME/bokf/tui.conf` (`~/.config/bokf/tui.conf`), written after a successful login. Precedence: `--socket`/`--user`, then `BOKFD_SOCKET`/ `BOKFD_USER`, then that file. For credentials from a Bitwarden/Vaultwarden item, use the user-space launcher — nothing runs as root: ```sh install -m 755 scripts/bokftui-bw ~/.local/bin/bokftui-bw BOKF_BW_ITEM=bokf bokftui-bw ``` `bokftui-bw` works with `rbw` (its agent keeps the vault unlocked for the session) or the official Bitwarden CLI `bw`, where the session key is cached in `~/.cache/bokf/bw-session` (mode 0600) so the master password is only asked when the session expires. On Node < 19 the launcher adds `--experimental-global-webcrypto` when the SDK's WASM crypto needs it. It reads the item's password, or the custom field named by `BOKF_BW_FIELD`, and execs the TUI. With several items matching `bokf`, pass one (`bokftui-bw bokf-anders`) or let it ask (`bokftui-bw` shows a numbered picker). `BOKF_BW_ITEM` does the same without an argument. Prefer a scoped, revocable API token over the account password: create one on the host, put it in a custom field (e.g. `token`) of the item, then: ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin --org 1 token.create \ '{"label":"tui-laptop","scopes":["read","write","admin"]}' BOKF_BW_KIND=token BOKF_BW_FIELD=token BOKF_BW_ITEM=bokf bokftui-bw ``` With `BOKFD_TOKEN` set the TUI logs in without showing the login screen. Revoke the token with `token.revoke` when a machine goes away. ## TLS and external users Put the ACME DNS credentials in the host's `.env` once (they are never committed): ```sh LEGO_DOMAIN=bokf.makandra.eu LEGO_EMAIL=anders@makandra.eu INWX_USERNAME=... INWX_PASSWORD=... BOKFD_SECRET_KEY=... # openssl rand -hex 32 ``` `BOKFD_SECRET_KEY` encrypts secrets that live in the database (the SMTP password) with AES-256-GCM; without it, setting or sending with a mail password fails. Keep it out of the repository and out of backups of the database — losing it only means re-entering the SMTP password. Forward port 8788 on the router to the host. The `certs` sidecar obtains and renews the certificate into `var/db/certs/certificates/`; `bokfd` reloads it in place. On the very first `up`, `bokfd` may restart a few times until the certificate exists — check `docker compose logs certs`. Clients outside the LAN connect with the DNS name: ```sh export BOKFD_SOCKET=tls:bokf.makandra.eu:8788 bokftui ``` No CA override is needed with a public ACME certificate (the system trust store suffices); `BOKFD_TLS_CA=/path/ca.pem` exists for private CAs. To invite someone, create an account and give it only the role and org it needs — no VPN, no SSH: ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin user.create '{"username":"revisor", ...}' docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin org.member_add '{"org":1,"username":"revisor","role":"viewer"}' ``` Agents use `token.create` instead of a password; tokens are scoped and revocable. Only the TLS port is forwarded, and every command still requires authentication (`meta` and `health` excepted). ## Web frontend (`https://bokf.makandra.eu/web`) The `web` service (image `bokf-web`, built from the `web` target of the same Dockerfile and shipped by `scripts/deploy.sh` with the daemon) serves bokftui in the browser: ``` browser ──443──▶ host Caddy (TLS) ──▶ 127.0.0.1:8790 bokf-web container Caddy: routing + forward_auth ├─ /web, /web/login, /web/logout ─▶ bokfweb (login gate) └─ /web/tty/* (gate OK only) ────▶ ttyd ─▶ bokftui-web ─▶ bokftui │ bokfd ◀── unix socket ┘ ``` - **Login gate** (`bokfweb`, `clients/bokfweb.c`): the login page checks the credentials with bokfd's own `session.open` — there is no second password store, and bokfd's audit and per-user lockout apply. The gate limits failed logins per client address (5 per 15 min) before bokfd's limit is reached. A login sets a cookie (`HttpOnly`, `Secure`, `SameSite=Strict`, 12 h) and redirects to `/web/tty/?arg=`. - **One login**: the terminal wrapper (`deploy/bokftui-web`) trades the handle for the bokfd session on the gate's internal `/redeem` (never routed by Caddy) and starts `bokftui` with `BOKFD_SESSION`, so the TUI opens logged in. Caddy's `forward_auth` lets a request reach ttyd only with a live cookie whose session owns the handle in the URL; a leaked URL is useless without the cookie. Quitting the TUI (or `/web/logout`) closes the bokfd session and with it the web session. - **Isolation**: every browser terminal is its own bokftui process with a private throwaway `HOME` (config, drafts, log), memory/CPU/file limits, and `BOKF_WEB=1`: the TUI refuses everything that would read or write files or start programs on the frontend (file browser, save prompts, downloads, the PDF viewer). No shell is ever offered. The container runs as uid 10001, read-only root, `/tmp` tmpfs, all capabilities dropped, `no-new-privileges`, pids/memory limits, and it sees bokfd only through the protocol socket (no database, no secrets, no certificates). - **TLS** is the host's reverse proxy. On the NAS, add to the existing Caddy (`/mnt/data/caddy/Caddyfile`) a site block like the others and reload it: ``` bokf.makandra.eu { tls { dns inwx { ... } # as for the other sites } reverse_proxy 127.0.0.1:8790 } ``` Port 443 is already forwarded for the other sites. The container publishes 8790 on the host's loopback only, so it is unreachable until that block exists. - `BOKF_WEB_MAX_SESSIONS` (default 20) caps concurrent terminals. `docker compose logs web` shows logins, logouts and failed attempts (never passwords). Not yet in the web version: uploading and downloading files (attachments, bank files, SRU/eSKD/årsredovisning files, PDFs). The TUI says so where it applies; use bokftui on a computer for those. ## Mock company ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin org.create \ '{"name":"Mock AB","org_nr":"556000-0000","fiscal_year_start_month":1}' ``` Develop against this org; agents get their own API token (`bokfctl token.create ...`, shown once). ## Backup and restore `backup.snapshot` makes a consistent copy of the live WAL database with `VACUUM INTO`; the result lands in `var/db/backup/` as `bokfd-.db` (the response carries its SHA-256). Interactive: ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin backup.snapshot ls var/db/backup ``` For unattended backups (restic, cron) use an admin-scoped token instead of the password, created once: ```sh docker compose exec -e BOKFD_PASSWORD='' bokfd \ bokfctl --user admin token.create '{"label":"backup","scopes":["admin"]}' # store the token (shown once) in a root-only file, e.g. /etc/bokf/bokfd-backup.env: # BOKFD_TOKEN=bokf_... ``` Then, before every restic run: take a snapshot through the daemon and let restic read only the snapshots — never the live database: ```sh set -a . /etc/bokf/bokfd-backup.env set +a docker compose --project-directory /mnt/data/bokf exec -T -e BOKFD_TOKEN \ bokfd bokfctl backup.snapshot >/dev/null find /mnt/data/bokf/var/db/backup -name 'bokfd-*.db' -mtime +14 -delete restic backup --exclude='/mnt/data/bokf/var/db/bokfd.db*' \ --exclude='/mnt/data/bokf/var/run' /mnt/data/bokf ``` The WAL and shared-memory files (`bokfd.db-wal`, `bokfd.db-shm`) must be excluded together with the main file; an inconsistent copy of a live SQLite database is worse than no backup. Restore: ```sh docker compose stop cp var/db/backup/.db var/db/bokfd.db rm -f var/db/bokfd.db-wal var/db/bokfd.db-shm docker compose start ``` To replace the mock with a real database from another host, restore its snapshot the same way (same or newer bokf version; older schemas migrate forward). SIE import is the alternative once the importer's CRLF/`#RAR` fixes land. ## Local development ```sh make -j"$(nproc)" && make test # no Docker required docker compose up --build # same image, local var/ data dir ``` `scripts/deploy.sh` always runs the tests before shipping. `var/`, `.env`, `*.db` and `*.se` are gitignored — real books never enter the repository.