Deploying
OpenBooks is not multi-tenant. Every set of books is its own instance: its own subdomain, its own database, its own pods. Nothing is shared but the two container images.
What an instance is
Section titled “What an instance is”- a namespace,
openbooks-<subdomain label> - a CloudNativePG
Clusternamedopenbooks-pg - the API and the web app, one Deployment each
- a Traefik
IngressRouteserving the app at/and the API at/apion one hostname
The API lives under /api on the same hostname as the web app rather than on
its own. That is not cosmetic: the session cookie is SameSite=Lax, so a
browser would not send it to an API on a different hostname, and every request
after login would arrive unauthenticated.
The images
Section titled “The images”openbooks-api |
Rust, built --release, migrations embedded at compile time |
openbooks-web |
nuxt generate output served by nginx, no Node at runtime |
The web image hard-codes its API base as the relative path /api. Because it is
relative, one image is correct for every instance — there is no per-instance
build.
Configuration
Section titled “Configuration”Beyond DATABASE_URL, which the database operator supplies, an instance needs
four origin values, all built from its own hostname:
WEB_ORIGIN— the hostname, with schemeAPI_ORIGIN— the hostname, with scheme, and the/apisuffix: the API is served under/apion the same hostname (see above), and this value is the base for every RFC 8414/9728 discovery document andWWW-Authenticatechallenge the API emits. Get it wrong and those all advertise URLs that resolve to the web app instead of the API.WEBAUTHN_RP_ID(the bare hostname) andWEBAUTHN_ORIGIN(with scheme)
WEBAUTHN_RP_ID deserves care. It defaults to localhost, and the value is
bound into every passkey at registration. Deploy with it wrong and enrolment
fails; change it afterwards and every passkey already registered stops working.
Administration
Section titled “Administration”There is no signup form, and that does not change in production. Users are created against the running API:
kubectl exec -n openbooks-books deploy/openbooks-api -- \ openbooks-api user add you@example.comuser list and mint-token work the same way.
Backups
Section titled “Backups”A CronJob named openbooks-pg-backup runs pg_dump --clean --if-exists at
03:00 daily, gzips the output, and writes it to a PersistentVolumeClaim
named openbooks-backups as pg_YYYYMMDD.sql.gz. Dumps older than 30 days
are pruned. It writes to a temp name and renames on success, so a failed run
never leaves a truncated file that looks like a real backup.
The PVC is node-local storage on the same disk as the database. On a
single-node cluster this backup protects against a bad migration, an
application bug, or an accidental DROP TABLE — it does not protect
against disk failure or node loss, because both copies live on the same
physical disk.
Off-site copy
Section titled “Off-site copy”The same CronJob also pushes the day’s dump to a Backblaze B2 bucket with
restic — content-addressed, deduplicated, and
integrity-checked on every snapshot, and this cluster’s existing pattern for
off-site backup (see ~/projects/hatchwarden). Credentials — a restic
repository (RESTIC_REPOSITORY, an S3-compatible URL into the B2 bucket),
its encryption passphrase (RESTIC_PASSWORD), and the B2 application key
(AWS_ACCESS_KEY_ID/AWS_SECRET_ACCESS_KEY) — live in a Secret named
openbooks-backup-b2 that this repo does not carry in cleartext. It’s
committed encrypted, as a SopsSecret
(deploy/thor-k3s/instances/books/sopssecret.yaml), which the
sops-secrets-operator (namespace sops) decrypts into the real Secret
on apply. .sops.yaml at the superproject root lists the two age
recipients allowed to decrypt it: the cluster operator’s key, and the
operator’s own workstation key, so the file can be edited and reviewed
locally, not just applied blind.
Unlike the local dump, a failed off-site push fails the Job. There’s no graceful skip here — real credentials are provisioned for every instance that has this section wired up, so a missing secret or an unreachable B2 bucket is treated as an incident, not a shrug. The local dump from the step above has already landed on the PVC by the time this step runs, regardless of how the off-site push goes.
Provisioning a new instance’s credentials means writing a new
sopssecret.yaml under its instances/<label>/ overlay and encrypting it
with sops --encrypt --in-place before it’s ever committed — never commit
the plaintext version, even transiently.
Restoring
Section titled “Restoring”From B2 (survives node loss, not just the local dump):
kubectl apply -n openbooks-<label> -f - <<'YAML'apiVersion: v1kind: Podmetadata: name: openbooks-restic-restorespec: restartPolicy: Never containers: - name: restic image: docker.io/restic/restic:0.19.1 command: ["sleep", "3600"] envFrom: - secretRef: { name: openbooks-backup-b2 }YAMLkubectl wait -n openbooks-<label> --for=condition=Ready pod/openbooks-restic-restorekubectl exec -n openbooks-<label> openbooks-restic-restore -- restic snapshotskubectl exec -n openbooks-<label> openbooks-restic-restore -- \ restic restore latest --target /tmp/restorekubectl cp openbooks-<label>/openbooks-restic-restore:/tmp/restore ./restoredkubectl delete -n openbooks-<label> pod/openbooks-restic-restore./restored now holds the extracted backups/pg_YYYYMMDD.sql.gz — restore
it the same way as the PVC copy below.
From the PVC (faster, but doesn’t survive node/disk loss — see above). There’s no long-running pod with it mounted, so a throwaway one is the simplest way to get a dump off it:
kubectl apply -n openbooks-<label> -f - <<'YAML'apiVersion: v1kind: Podmetadata: name: openbooks-restore-shellspec: restartPolicy: Never containers: - name: shell image: docker.io/library/postgres:18 command: ["sleep", "3600"] volumeMounts: - { name: backups, mountPath: /backups } volumes: - name: backups persistentVolumeClaim: { claimName: openbooks-backups }YAMLkubectl wait -n openbooks-<label> --for=condition=Ready pod/openbooks-restore-shellkubectl cp openbooks-<label>/openbooks-restore-shell:/backups ./restored-backupskubectl delete -n openbooks-<label> pod/openbooks-restore-shellThe PVC is ReadWriteOnce, so do this when the nightly Job isn’t running.
Loading either dump into the database — pipe the decompressed dump into
the CNPG primary’s psql (find the pod with kubectl get pods -n openbooks-<label> -l cnpg.io/cluster=openbooks-pg; on a single-instance
cluster it’s openbooks-pg-1):
gunzip -c pg_20260807.sql.gz | \ kubectl exec -i -n openbooks-<label> openbooks-pg-1 -- psql -U openbooks -d openbooksThis is destructive. The dump was taken with --clean --if-exists, so
replaying it drops and recreates every object it contains against whatever
is live right now. There is no dry-run — restore into a scratch instance
first if you need to inspect a dump rather than apply it.
Do not seed a deployed instance
Section titled “Do not seed a deployed instance”seed.sh clears its own date window before writing. Against real books it
deletes real transactions. It is for development only.