# Backup — where it stands, and what is still missing A plan, not an implementation. It says what is already safe, what is not, and in what order to close the gap. --- ## 1. What is already there `deploy/db-backup.sh`, run nightly by `family-db-backup.timer`: * `VACUUM INTO` — a **consistent** dump. Not a file copy: the database runs in WAL mode, and a copy taken while the site is writing has no WAL with it. * `PRAGMA integrity_check` plus a row count **before** the dump is kept. * gzip, then a second check by unpacking it again — a real restore test, so a dump that cannot be read is noticed the night it is made and not a year later. * 14 generations locally, 14 on the web share, plus `family-latest.db.gz`. * An optional heartbeat URL, so a monitor can say "the backup did not run". That part is sound and stays as it is. Everything below is what it does *not* do. ## 2. What is not covered | Gap | What it costs you | |---|---| | **The settings are not backed up.** `/etc/family/env`, the proxy secret, the identity-provider token. | The database restores, and the site will not start: paths, auth mode and the shared secret are gone. Rebuilt by hand, from memory. | | **The photographs are not backed up by Roamlight at all.** Both trees are network shares. | Whether they survive depends entirely on what the file server does. Roamlight neither knows nor says. If the answer is "nothing", the dump is a catalogue of photographs that no longer exist. | | **The library is not backed up.** Masters, derivatives, map tiles. | Rebuildable from the originals — but that is days of CPU for a full library, and the site is half-blank until it finishes. | | **There is no restore command.** | A restore today is a shell session and a good memory. That is exactly the moment when neither is available. | | **Nothing is visible in the app.** | Nobody notices a backup that has been silently failing for three weeks. The heartbeat covers this only if a monitor is actually watching. | | **Docker and the LXC have no backup at all.** The script is a systemd unit; the container has no systemd. | Anyone who installs Roamlight from `compose.yaml` has no backup and is not told so. | ## 3. What has to be saved — three classes, three rules The three are not equally valuable, and treating them the same is what makes backups too big to actually run. **A · Irreplaceable — the originals.** Every photograph, exactly as it came off the camera. This cannot be regenerated by anything. It is also the big one: a family library is hundreds of gigabytes to terabytes. → *Rule: never inside a Roamlight archive.* Roamlight's job is to **know** where they are, **check** that something is copying them, and **say so when nothing is**. The copying itself belongs to the file server (snapshots, replication) or to a dedicated tool — one that can do incremental transfer over terabytes, which a tar file cannot. **B · Expensive but derivable — the library and the derivatives.** Masters, the five web sizes, the poster frames, the map tiles. → *Rule: optional, and off by default.* Worth copying when the CPU cost of a rebuild matters more than the disk; worth skipping otherwise. A restore without it works from the first minute and fills in as it converts. **C · Small and critical — the database and the settings.** A few megabytes. It holds everything a photograph on disk cannot tell you: which album it is in, who may see it, who is on it, the journeys drawn on the map, the share links, the accounts, the paired devices. → *Rule: nightly, verified, in at least two places, one of them off the machine.* This is the part that already works, minus the settings. > The whole point: **class C is the backup people forget, and it is the only > one that fits in a mail attachment.** A library with no database is a heap > of folders. A database with no library is a catalogue that can be pointed at > a restored heap of folders and works again. ## 4. The design One command, `roamlight backup`, and one bundle. The bundle is class **C** always, class **B** only when asked. ``` roamlight-backup-20260907-0132.tar.gz ├── manifest.json what this is: version, schema version, row counts, │ the configured paths, sha256 of every member ├── db.sqlite3.gz VACUUM INTO, verified (what the script does today) ├── env the settings, secrets stripped or encrypted └── secrets.age the proxy secret and friends — separately, encrypted ``` * **`manifest.json` is what makes it a backup and not an archive.** It records the row counts and the schema version at the moment of the dump, so a restore can say *"this bundle holds 6 548 photographs and schema 14; this installation is schema 15 — migrating"* instead of failing in the dark. * **The paths are recorded but not restored.** The library may well live somewhere else on the new machine. The restore asks; the manifest only says what it used to be. * **The originals are recorded as a fingerprint, not as content**: the tree root, the number of files, total bytes, and the newest mtime. Enough for a restore to say *"the originals are 41 files short of what this backup expected"* — which is the failure everyone discovers too late. ### Order, and why it matters Files first, database second. Never the other way round. A photograph uploaded between the two steps is then a **file with no row** — invisible until the next scan picks it up, and harmless. Reverse the order and you get a **row with no file**: a gallery full of broken images and a 500 on the album page. The same rule applies to a restore, in reverse: files back first, then the database. ### The secrets The bundle carries password hashes (argon2id), share tokens and device tokens. Anyone holding it can pair as a device that is already trusted. → Secrets are **encrypted separately** (`age`, one recipient, the key kept where the bundle is not), and the bundle itself is `0600`. A backup that travels to a cloud drive is encrypted whole or it does not travel. → `roamlight restore --revoke-devices` as the default on a restore into a new machine: bring the albums back, make the phones pair again. ## 5. Restore ```bash roamlight restore roamlight-backup-20260907-0132.tar.gz --dry-run ``` `--dry-run` first, always, and it is the mode the documentation leads with. It prints what it *would* do: schema migration, row counts, which paths it would use, and — the important line — how many photographs in the database have no file on disk at the given originals path. Then, without the flag: settings in place, database in place, migrations run, `foreign_key_check`, a scan queued. It never writes over an existing database without `--force` and never touches the originals tree at all. ## 6. In the app An admin page, `/admin/backup`, that answers three questions: 1. **When did it last work?** Green when the newest verified dump is younger than 48 hours, red otherwise — with the age in plain words. 2. **What is in it?** Size, row counts, schema, where the copies are. 3. **Give it to me.** A "download the settings and database" button. Small, works from a phone, and is what someone actually does before moving house or reinstalling. Plus one line on the dashboard when the newest backup is older than two days. That is the whole point of the page: **not to make backups, but to make a broken backup impossible to ignore.** ## 7. Docker and the LXC The nightly job cannot be a systemd timer in a container. * **Docker**: the entrypoint starts a small scheduler in the background (the supervisor already runs there), or a documented `docker compose exec roamlight roamlight backup` for a host cron. The first is the default, so that someone who copies `compose.yaml` gets backups without reading anything. `/data/backups` is inside the data volume, and the README says plainly that a backup in the same volume is not a backup — with `FAMILY_BACKUP_DIR` to point it at a second mount. * **LXC / from a checkout**: the timer as today, installed by `install.sh` (it currently installs the sync timers but not this one on every path). ## 8. Order of work | | | Effort | |---|---|---| | 1 | Settings into the nightly dump; secrets encrypted separately | half a day | | 2 | `roamlight backup` / `roamlight restore --dry-run` as real commands, manifest included | 1–2 days | | 3 | `/admin/backup` — last run, contents, download; the dashboard warning | 1 day | | 4 | Docker: run it without systemd, and say so in the README | half a day | | 5 | Originals fingerprint + "nothing is copying your photographs" warning | half a day | | 6 | Optional class **B** (library and derivatives) as an incremental copy | 1 day | Step 1 and 3 together already remove the two ways this actually fails in practice: a restore that cannot start, and a backup nobody noticed had stopped. ## 9. Not in scope * Roamlight will not copy terabytes of originals itself. It will refuse to pretend it does. * No cloud provider is built in. The bundle is a file; where it goes is the file server's business. * Versioned history of individual photographs — the originals tree is only ever added to, which is the version history. ## 10. To decide * Where does the bundle go besides the web share? A second machine, or an external disk that is not always plugged in? * Are the originals copied anywhere today, by the file server? Everything in class **A** depends on that answer, and Roamlight cannot find it out. * How long is a share link allowed to survive a restore — do old links keep working, or are they all cancelled?