Skip to content

feat: file-backup copies SQLite consistently, and copies off-cluster for irreplaceable volumes #470

Open

Description

@ExtraToast

Problem

recoverable and irreplaceable derive a backup from the Process's engine, and a volume without an engine is E_DURABILITY_WITHOUT_ENGINE. Many third-party apps keep their state as files or SQLite on a volume, with no engine to name: jellyfin (users, watch history), sonarr, radarr, bazarr, prowlarr, qbittorrent, and karakeep's data directory. Each gets one of two wrong answers:

  • reconstructible, which loses real data;
  • or it stays on the old path.

What to build

A platform backup method for file-shaped state. Either it is an engine keyed for files, or a volume without an engine falls back to it.

  • It produces a consistent copy: SQLite is copied through its online backup API, or the Process is quiesced.
  • It keeps the class's retain copies, and makes the off-cluster copy for irreplaceable.

Acceptance criteria

  • An ADR records the decision, and spec/v1 chapters 10 and 14 carry it, with a worked example and refusals.
  • Both implementations land it against the same oracle files.
  • A restore from the method's copy is rehearsed on one real volume.

Blocks

Moving jellyfin, arr, downloads and karakeep onto the estate path.

Activity

  1. added
    type: featureNew user-facing or operator-facing capability.
    area: deployhomelab-deploy, deploy-v2, manifests, and rollout flow.
    status: triageNeeds initial review and classification.
    on Oct 17, 2026
  2. ExtraToast commented on Oct 17, 2026

    @ExtraToast
    ContributorAuthor

    Most of this already exists, and was missed when this was filed: chapter 10's engine vocabulary has files. The Platform document maps it to the file-backup method, which JorisJonkers-dev/delivery builds and the estate locks (ghcr.io/jorisjonkers-dev/delivery/file-backup). So a recoverable volume of SQLite or plain files is engine: files today, and jellyfin's and the arr apps' config volumes need nothing new.

    What's still open:

    • A consistent SQLite copy. file-backup reads the volume as files, so a copy taken mid-write can be torn. Either the method copies SQLite through its online backup API, or a Process declares it can be quiesced.
    • The off-cluster copy irreplaceable requires. JorisJonkers-dev/delivery's README says none exists yet for any method.

    karakeep moved without this, since its state streams to Garage through Litestream (JorisJonkers-dev/estate#134).

  3. changed the title [-]feat: a backup method for file-shaped state with no engine[/-] [+]feat: file-backup copies SQLite consistently, and copies off-cluster for irreplaceable volumes[/+] on Oct 17, 2026
  4. ExtraToast commented on Oct 26, 2026

    @ExtraToast
    ContributorAuthor

    Design draft (2026-10-26), for the maintainer to decide

    What stays open, per the comment above: a consistent copy of SQLite under engine: files, and the off-cluster copy irreplaceable asks for. Each part below gives the options and a recommendation.

    1. A consistent SQLite copy

    file-backup mounts the volume read-only at /data (chapter 30's backup row), on the volume's own node. That rules out the obvious fix. SQLite's online backup API, and VACUUM INTO, must write the database's -shm file even to read a WAL-mode database, so neither works on a read-only mount. Jellyfin, sonarr, radarr, bazarr and prowlarr all run SQLite in WAL mode.

    option what changes cost
    A. Online backup, read-write mount For engine: files, the render mounts the volume read-write, and the method finds each SQLite file by its header (SQLite format 3\0) and copies it through the online backup API. Every other file is copied as today. The backup pod can write the app's volume; a method bug could damage it. Chapter 30's contract changes for one engine. A pure-Go SQLite library (modernc.org/sqlite) keeps the image distroless.
    B. Copy and verify, read-only mount The method copies db, -wal and -shm together, then runs PRAGMA integrity_check on the copy and retries a few times on failure. The mount stays read-only. Not transactionally consistent: a copy can miss the last transactions, but it never keeps a torn file, because the check refuses one.
    C. The app's own export A Process names the directory its app writes consistent backups to (the arr apps write /config/Backups/*.zip; Jellyfin 10.11 has its own backup), and the method copies that alone. Per app, and as fresh as the app's own schedule. Nothing to build in the method, but a model field.
    D. Quiesce The Process declares it may be stopped, and the backup scales it down around the copy. Nightly downtime, and the backup identity needs Kubernetes API access to scale. Rejected.

    Recommendation: B now, A later if a restore ever shows lost transactions. B keeps the read-only guarantee, needs no model change, and the integrity check makes a bad copy fail loudly instead of being kept. These apps lose at most the minutes before the copy, which is acceptable for config and watch history.

    2. The off-cluster copy

    The model already has it. durability.irreplaceable.offCluster names the destination (s3://jorisjonkers-dev-backups, Contabo Object Storage), the credential path (secret/data/platform/backup/off-cluster), and the egress ranges, and the render hands the plan BACKUP_OFF_CLUSTER and the credential. Only the method is missing: today it writes the local generation and then fails with ErrOffCluster, so the gap is never silent.

    To build, in JorisJonkers-dev/delivery's internal/backup, for every method (files, postgres, rabbitmq):

    • Upload each new generation to <bucket>/<project>/<process>/<volume>/<generation> with S3 SigV4 PUT. A small hand-written signer keeps the dependency list short; minio-go is the alternative.
    • Verify: HEAD the object, and compare size and SHA-256 (sent as x-amz-checksum-sha256). A mismatch fails the run.
    • Retain off-cluster with the same retain count as the local copies: list the prefix and delete beyond it. The alternative is the bucket's lifecycle rules, which move retention out of the model.
    • Encryption: client-side, before upload, with age to a public key the Platform document names. Then the provider never holds readable copies of photos and personal data, and the private key stays off the cluster, in a safe place. The alternative is relying on the provider's encryption at rest.

    Recommendation: all four as listed, with client-side age encryption. Restore then needs the private key, which is the point.

    3. Proof

    A restore rehearsal on one real volume, as the criteria ask. Restore jellyfin's config volume from an off-cluster copy into a scratch namespace, and start Jellyfin against it read-only.

    Order

    1. B (SQLite) and the off-cluster upload in delivery, released together.
    2. The estate's Platform document gains the age recipient.
    3. Then each app moves to irreplaceable or recoverable as its data deserves.
  5. ExtraToast commented on Oct 26, 2026

    @ExtraToast
    ContributorAuthor

    Decided (2026-10-26, the maintainer): option B for SQLite (copy with -wal and -shm, PRAGMA integrity_check on the copy, retry on failure; the mount stays read-only), and client-side age encryption for the off-cluster copy, to a public key the Platform document names. The rest of the draft stands: upload in every method, verify by size and SHA-256, and retain off-cluster by the class's retain.

  6. added and removed
    status: triageNeeds initial review and classification.
    on Oct 26, 2026
  7. ExtraToast commented on Oct 26, 2026

    @ExtraToast
    ContributorAuthor

    Progress and one more decision (2026-10-26):

  8. ExtraToast commented on Oct 27, 2026

    @ExtraToast
    ContributorAuthor

    Resume here

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    area: deployhomelab-deploy, deploy-v2, manifests, and rollout flow.type: featureNew user-facing or operator-facing capability.

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions