Skip to content

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.

  • a namespace, openbooks-<subdomain label>
  • a CloudNativePG Cluster named openbooks-pg
  • the API and the web app, one Deployment each
  • a Traefik IngressRoute serving the app at / and the API at /api on 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.

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.

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 scheme
  • API_ORIGIN — the hostname, with scheme, and the /api suffix: the API is served under /api on the same hostname (see above), and this value is the base for every RFC 8414/9728 discovery document and WWW-Authenticate challenge 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) and WEBAUTHN_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.

There is no signup form, and that does not change in production. Users are created against the running API:

Terminal window
kubectl exec -n openbooks-books deploy/openbooks-api -- \
openbooks-api user add [email protected]

user list and mint-token work the same way.

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.

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.

From B2 (survives node loss, not just the local dump):

Terminal window
kubectl apply -n openbooks-<label> -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: openbooks-restic-restore
spec:
restartPolicy: Never
containers:
- name: restic
image: docker.io/restic/restic:0.19.1
command: ["sleep", "3600"]
envFrom:
- secretRef: { name: openbooks-backup-b2 }
YAML
kubectl wait -n openbooks-<label> --for=condition=Ready pod/openbooks-restic-restore
kubectl exec -n openbooks-<label> openbooks-restic-restore -- restic snapshots
kubectl exec -n openbooks-<label> openbooks-restic-restore -- \
restic restore latest --target /tmp/restore
kubectl cp openbooks-<label>/openbooks-restic-restore:/tmp/restore ./restored
kubectl 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:

Terminal window
kubectl apply -n openbooks-<label> -f - <<'YAML'
apiVersion: v1
kind: Pod
metadata:
name: openbooks-restore-shell
spec:
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 }
YAML
kubectl wait -n openbooks-<label> --for=condition=Ready pod/openbooks-restore-shell
kubectl cp openbooks-<label>/openbooks-restore-shell:/backups ./restored-backups
kubectl delete -n openbooks-<label> pod/openbooks-restore-shell

The 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):

Terminal window
gunzip -c pg_20260807.sql.gz | \
kubectl exec -i -n openbooks-<label> openbooks-pg-1 -- psql -U openbooks -d openbooks

This 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.

seed.sh clears its own date window before writing. Against real books it deletes real transactions. It is for development only.