GitBasedDocs now has a Dockerfile, a compose file and backups you turn on with BACKUP_ENABLED. Each archive is a VACUUM INTO copy plus the asset files, and the copy is edited before it is packed so a restored app catches up on its own.
The deploy notes for GitBasedDocs said "put data/ on a persistent volume and back up the database and the assets together". That is advice, not a backup. The request was specific: a flag in the docker compose file that turns backups on or off.
Where the backup runs
Two shapes were on the table. A sidecar container with sqlite3 and cron, or a schedule inside the app.
I went with the app. It already starts one in-process timer from instrumentation.ts for the sync recheck, so a backup timer follows the same pattern. It stays one container, the flag sits in the app's own environment block, and the logic can be checked with Bun like everything else.
environment:
DATABASE_URL: file:./data/app.db
ASSET_DIR: ./data/assets
# Backups of the database and cached assets into the gbd-backups volume.
# Set to "false" to turn them off.
BACKUP_ENABLED: "true"
BACKUP_DIR: ./backups
BACKUP_INTERVAL_HOURS: "24"
BACKUP_KEEP: "7"
Outside compose, it is off unless BACKUP_ENABLED is exactly "true". A plain bun run start never starts writing archives by surprise.
What goes in an archive
The database is copied with VACUUM INTO, which writes a consistent copy of a live database in one statement, WAL mode or not. Copying the file with cp while the app writes is how you get a backup that will not open.
The assets are easier than they look. Files in the asset folder are named by git blob sha and written to a temp name first, then renamed. A file under a hash name never changes, so a plain copy of it is safe.
The run waits for any sync in flight first, because sync writes pages and asset files together. Then it packs app.db and assets/ into one .tar.gz under a .part name and renames it, so a half written archive is never listed and never restored.
tar runs through execFile with an argument list, so no shell ever parses a path.
Deciding when one is due
A timer that fires every 24 hours from boot takes an extra backup after each restart, or skips one when the container restarts every few hours. So the schedule reads the folder instead:
// "gbd-20260913-140502.tar.gz", in UTC, so names sort by time as plain text.
export function isDue(newest: string | undefined, now: Date, intervalMs: number): boolean {
const at = newest ? archiveTime(newest) : null
return at === null || now.getTime() - at >= intervalMs
}
It checks hourly. The name is the record of when the last backup happened, and it survives restarts because it lives on the volume.
Editing the copy before packing it
A restored database remembers the last synced commit. The app would compare it to the branch head, see a match if nothing was pushed since, and never notice that asset files missing from the archive need fetching again.
So the backup opens the copy and clears it:
const copy = createClient({ url: `file:${dbFile}` })
await copy.execute("UPDATE repo_connections SET last_synced_sha = NULL")
A restored app reads the repo tree on boot and catches up. Asset sync also got one extra condition: a row whose file is missing is fetched again, instead of being skipped because its hash matches.
A later change added a second edit. The search index points at page rows by SQLite's hidden rowid, which a vacuum may renumber, so the backup rebuilds the index inside the copy.
Restore is one extract into the data volume:
docker compose stop app
docker compose run --rm --entrypoint sh app -c \
'rm -rf /app/data/* && tar -xzf /app/backups/gbd-20260913-140502.tar.gz -C /app/data'
docker compose start app
Health, and a healthcheck without curl
GET /api/health answers 503 only when the database does not open. A failed GitHub sync shows in the body but keeps the check green, since restarting the container would not fix GitHub. It answers without a session, so the body carries a status and an age and no repo or project names.
The runtime image is node:22-slim, which has no curl, so the compose healthcheck uses Node:
test: ["CMD", "node", "-e", "fetch('http://localhost:3000/api/health').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
What is tested, and what is not
backup.check.ts runs three backups with a keep count of two and checks the oldest is gone. It extracts the newest with tar, opens the database inside, and checks the project is there, the synced commit is cleared, and the live database kept its own.
I also booted the production build with backups on. A minute later it printed [backup] wrote data/boot-backups/gbd-20260913-122350.tar.gz (5 KB).
The Docker image itself has not been built. The Docker daemon was not running on the machine I worked on, so the card sits in review until docker compose up -d --build has served a login page and survived a restart. Writing that down beats claiming a deploy works because the YAML validates.