A FHIR Backup Is Not a Database Copy
A FHIR Backup Is Not a Database Copy
Primary query: FHIR server backup and restore
Version: dev
Source revision: 7e036d413940d3978a5042409d387a4d52f2485c
Last verified: September 30, 2026
FHIR version: R4 4.0.1
Data: synthetic only
A healthcare server that stores everything in one database can get away with talking about “the database backup.”
Ilavrita cannot.
A Binary resource is split across two stores: the FHIR row lives in SQLite, while the payload bytes live on disk. That means a backup is only useful if those two stores describe the same world.
A copy of ilavrita.db without the payloads is incomplete. A copy of the payload directory without the database is just a pile of bytes. And copying both in the wrong order can produce the worst possible failure: a row that says a document exists while the document itself never made it into the archive.
So the backup design starts with one rule:
The database and payload store have to be captured together, in an order that can fail safely.
At the current dev revision, Ilavrita exposes three operator commands:
ilavrita backup <directory>
ilavrita verify-backup <directory>
ilavrita restore <directory>
They are CLI commands, not HTTP endpoints. The archive contains the install's database, payload files, and a manifest with SHA-256 digests and sizes. Restore verifies the whole archive before writing anything back.
That is a real backup/restore implementation. It is not yet a complete upgrade-and-rollback story, and it is not evidence of production readiness.
Ilavrita remains a developer preview. Use synthetic data only.
Why a SQLite file copy is not enough
The first problem exists even before payloads enter the picture.
Copying a live SQLite file directly is not the same thing as taking a database snapshot. A write may be in progress. Write-ahead-log state may not have been checkpointed into the main database file. A raw filesystem copy can therefore capture something other than a coherent database state.
Ilavrita does not use cp ilavrita.db backup.db.
Its SQLite adapter uses:
VACUUM INTO ?
to create the database snapshot.
The implementation describes why: VACUUM INTO reads one transaction's view and writes a complete database without stopping writers.
See packages/storage/pocketbase/snapshot.go.
That solves the database-consistency problem.
It does not yet solve the two-store problem.
The harder problem: rows and payloads can move independently
A Binary write in Ilavrita involves two durable things:
- a database row describing the FHIR resource,
- payload bytes on disk.
The live write path is already ordered around that fact. Payload bytes are made durable before the database transaction commits. If the process dies in between, the safe failure is an orphaned file that no database row names.
That wastes space.
The dangerous failure would be the inverse: a committed row that names bytes that never became durable.
The backup logic deliberately preserves the same direction of failure.
The repository's current known-limitations document states the rule clearly: snapshot the database first, then copy payloads.
Why that order?
Imagine a Binary write happens while the backup is running.
If the database snapshot happens first, that new Binary row is either inside the snapshot or it is not.
If it is inside, its payload already existed before the row committed, so copying payloads afterwards can include it.
If the Binary write lands after the snapshot, the payload may still be copied. That leaves an extra file in the archive that no row references.
Extra bytes are untidy.
Missing bytes referenced by a row are data loss.
The backup chooses the untidy failure mode.
Source: packages/backup/take.go.
The manifest is written last on purpose
A backup directory is not considered complete just because it contains files.
Ilavrita writes:
manifest.json
ilavrita.db
payloads/
...
The manifest records:
- archive format version,
- backup timestamp,
- every archived file,
- file size,
- SHA-256 digest.
The interesting detail is when the manifest appears.
It is written last.
That gives an interrupted backup a useful shape: a directory may contain partial database/payload copies, but it has no manifest declaring itself complete.
The restore path refuses a directory that does not contain a readable manifest.
That is better than trying to infer completeness after the fact.
A half-written backup should look broken.
It should not look almost valid.
Verification is a first-class command
A backup that has never been read is an assumption.
Ilavrita therefore has:
./bin/ilavrita verify-backup ./backup-2026-09-30
The verifier reads the manifest, walks every file named by it, recomputes its size and SHA-256 digest, and refuses the archive if anything differs.
The test suite deliberately damages archives in several ways:
- changes a payload,
- swaps a payload for different bytes of the same size,
- replaces the database,
- removes a file.
Each damaged archive must fail verification and must not restore.
The same-size payload case is worth calling out. A verifier that only compared file sizes would miss it. The digest is what catches that class of corruption.
See TestAnArchiveThatWasTamperedWithIsRefused in packages/backup/archive_test.go.
Restore verifies first, then writes
The restore path does not discover corruption halfway through copying an archive.
It calls the same full verification routine first.
Only a verified archive proceeds.
Then restore uses the reverse safe ordering from backup:
payloads first
database last
Again, the direction of failure is the point.
If restore is interrupted while copying payloads, the destination may contain files no database row names.
If the database had been restored first, interruption could leave rows pointing at payloads that had not been copied yet.
The database is therefore the last thing restored.
Source: packages/backup/restore.go.
Restore also refuses to overwrite a live install
A restore target that already contains ilavrita.db is rejected.
That is a deliberate operational guardrail.
Restoring over a live install is not an idempotent housekeeping action. It is destructive replacement of the install's state.
The CLI itself says the old install should be moved aside deliberately first.
The test TestARestoreLeavesALiveInstallAlone asserts that an occupied data directory is refused.
That means the restore workflow forces the operator to make the destructive boundary explicit instead of silently treating “restore” as “merge.”
The manifest is untrusted input
One easy mistake in backup software is to trust paths from the backup itself.
A manifest is just a file.
Someone can edit it.
If it says:
../../../etc/something
a naive restore routine can be turned into an arbitrary filesystem write.
Ilavrita resolves every manifest entry under the archive root and refuses paths that escape it.
The test TestAManifestCannotNameSomewhereElse edits a manifest to point outside the archive and expects verification to fail with an unsafe-path error.
That check applies before restore follows the path.
A backup format is an input parser. It should be treated like one.
What exactly is in the archive?
At this revision, the archive contains:
ilavrita.db,- the payload tree,
manifest.json.
It does not archive PocketBase's own data.db and auxiliary.db, because Ilavrita's application rows are stored in ilavrita.db and the runtime can recreate its own files.
The archive includes everything that Ilavrita stores in its application database: project state, FHIR rows, password hashes, sealed second-factor material, audit records, and related persisted state.
That makes the archive extremely sensitive.
Files are written with mode 0600; directories use 0700.
The tests assert that archive files are not readable by group or other users.
But permission bits are not encryption.
The archive is not encrypted by Ilavrita.
The repository explicitly leaves encryption-at-rest for backup media to the operator.
That distinction should survive every deployment diagram: “restricted filesystem permissions” and “encrypted backup” are different controls.
The sealing key is intentionally not backed up
Ilavrita's TOTP second-factor secrets are sealed with ILAVRITA_SEALING_KEY.
That key is not stored in the backup archive.
This is deliberate.
Putting the database and the key that decrypts its protected secrets in the same archive would collapse the separation.
But it creates an operational consequence: restoring the database without restoring the same sealing key leaves the stored second-factor material unreadable.
So a real disaster-recovery plan needs two independently protected things:
- the verified Ilavrita backup archive,
- the sealing key used by that deployment.
The code reads ILAVRITA_SEALING_KEY from environment or secret-store configuration. The backup code never adds it to the manifest.
This is the kind of detail that makes “we have backups” very different from “we can recover the service.”
The operator workflow
After building the current source:
make build
take a backup:
./bin/ilavrita backup ./backup-2026-09-30
verify it immediately:
./bin/ilavrita verify-backup ./backup-2026-09-30
A successful verifier prints the number of files and the archive timestamp.
Restore is intentionally a separate operation:
./bin/ilavrita restore ./backup-2026-09-30
but only when the configured Ilavrita data directory contains no existing database.
Do not use a restore command as a casual test against a live install. Use a separate empty data directory or disposable environment.
For lower-level verification of the backup package itself:
go test ./packages/backup -count=1
The current test suite covers both stores, restored row/payload equality, tamper detection, occupied restore targets, missing manifests, unsafe manifest paths, installs with no payloads, and archive permissions.
What I could and could not execute here
I attempted the required clean clone in a fresh runtime directory:
git clone --recurse-submodules https://github.com/Ilavrita/Ilavrita.git /tmp/ilavrita-backup-verify
It failed before source checkout:
fatal: unable to access 'https://github.com/Ilavrita/Ilavrita.git/':
Could not resolve host: github.com
Because the clone never completed, I did not run go test ./packages/backup in this runtime and do not claim a fresh local passing test run.
Instead, the current dev branch, backup package, SQLite snapshot adapter, CLI commands, README, ROADMAP, security documentation, known-limitations documentation, and backup tests were inspected through the connected GitHub repository at revision 7e036d413940d3978a5042409d387a4d52f2485c.
Payload's synced documentation is still pinned to older revision 946221398bc8fa8731838e98e3d040c102df7b63, so current GitHub dev is authoritative where the two differ.
Backup is not rollback
The current ROADMAP marks backup and restore as implemented.
It separately leaves upgrade and rollback procedures incomplete.
Those are not the same capability.
A backup can put a known archive back into an empty data directory. That proves the archive format and restore mechanics described above.
It does not automatically prove that every future release can upgrade an old install and then safely roll back to an earlier application version with compatible schema and semantics.
The repository does have idempotent migrations, seeds, and backfills recorded as super jobs. But the current roadmap explicitly says the release-to-release rollback procedure is not written down.
So the accurate claim is:
Ilavrita has tested backup, verification, and restore mechanics. It does not yet claim a complete upgrade/rollback operating procedure.
That boundary matters more than the feature checkbox.
The useful failure mode is the one you choose in advance
Backup code is mostly about deciding how things are allowed to go wrong.
Database first, payloads second means a concurrent write can create an extra unreferenced payload, not a missing referenced one.
Manifest last means interruption creates an obviously incomplete archive.
Verify before restore means corruption is discovered before the destination is mutated.
Payloads first, database last means interrupted restore leaves harmless extra files rather than live rows pointing into absence.
Refusing an occupied destination turns overwrite into an explicit operator decision.
Keeping the sealing key outside the archive preserves secret separation, while making recovery planning more demanding.
None of those choices makes a system production-ready by itself.
They do make the failure semantics reviewable.
And for infrastructure that may eventually hold clinical data, that is the standard worth aiming for: not “backup succeeded,” but “we can explain exactly what remains true if it does not.”
For the current product boundary, see the Ilavrita docs, known limitations, and the roadmap.
