1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
|
# 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='<admin-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 binaries are cross-compiled here
(`deploy/Dockerfile.cross`, ~20 s on a PC),
- 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:<tag>`: locally and ships it with `docker save | gzip | ssh
docker load`, or — when the host runs a different CPU architecture —
builds it natively 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.
Cross-architecture builds are automatic: `uname -m` is compared over SSH and
a mismatch switches to a remote build. Override with `BOKF_BUILD=local` or
`BOKF_BUILD=remote` (also settable in `.env`). A remote build pulls the
Debian base image inside a container, so the host needs outbound network
access but still no toolchain.
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
Run clients inside the container — no host toolchain needed:
```sh
docker compose exec bokfd bokftui # interactive TUI
docker compose exec -e BOKFD_PASSWORD='<pw>' bokfd \
bokfctl --user admin fiscal_year.list
```
The clients honor `BOKFD_SOCKET`; 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.
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='<pw>' 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=...
```
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='<owner-pw>' bokfd \
bokfctl --user admin user.create '{"username":"revisor", ...}'
docker compose exec -e BOKFD_PASSWORD='<owner-pw>' 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).
## Mock company
```sh
docker compose exec -e BOKFD_PASSWORD='<pw>' 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-<timestamp>.db` (the response carries its SHA-256). Interactive:
```sh
docker compose exec -e BOKFD_PASSWORD='<pw>' 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='<pw>' 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/<snapshot>.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.
|