Skip to content

feat(backup): back up Matrix — the timeline and the recordings - #87

Merged
arthware-dev merged 9 commits into
mainfrom
feat/backup-snapshots
Sep 11, 2026
Merged

arthware-dev merged 9 commits into
mainfrom
feat/backup-snapshots

Conversation

@arthware-dev

@arthware-dev arthware-dev commented Sep 11, 2026

Copy link
Copy Markdown
Contributor

Backs up the Matrix homeserver. The timeline goes as dated snapshots, the
uploads as an append-only archive.

What lands on the vault

Path Contents
data/messages-synapse/ Dated .tar.gz per run: database dump, signing key, homeserver.yaml, MANIFEST.json. Around 220 KB.
data/messages-media/ Voice messages, photos and files sent in chat.

Nothing to configure. The next stack backup sync adds both alongside
existing photo and document archives; it does not touch what is already
there. pg_dump runs against the live server, so there is no downtime
and nobody is logged out.

New manifest contract

A stacklet declares state that cannot be rsynced:

[[backup.snapshot]]
name     = "synapse"
postgres = { container = "stack-messages-db", database = "synapse", user = "synapse" }
include  = ["{data_dir}/messages/synapse/homeserver.yaml",
            "{data_dir}/messages/synapse/*.signing.key"]

Snapshots run before the sync, so a dump is never newer than the files it
references. Each tarball records the image versions and digests that
produced it, for a restore to check against.

Paperless and Immich have the same gap and the same declaration fits them.

Also in here

  • min_files is removed from the manifest contract. Sources are compared
    against their own previous run instead, which needs no configuration and
    catches losses at any scale.
  • A source with no data yet is skipped and reported rather than failing
    the whole run.

Docs

  • docs/admin-guide.md: what a snapshot contains, and the restore steps.
  • docs/stack-reference.md: the [[backup.snapshot]] fields.

Restore remains manual. stack backup restore and on_restore hooks are
still planned.

The backup stacklet could only archive directories that never change:
photos, scanned documents. That left every database out, and for Matrix
it left out the part that makes a recording a message. The media store
holds the family's actual voice recordings; the database holds who spoke,
in which room, when, and which file belongs to which event. Without it
those recordings are anonymous blobs.

Postgres cannot be rsynced. Its files change under you and a copy taken
mid-write will not restore. So a snapshot dumps it instead and packs the
dump with the files a homeserver cannot come back without: the signing
key, which is its identity, and homeserver.yaml, whose macaroon secret
keeps every existing login valid. One dated tarball per run, never
overwriting the last, which turns a mutable database into the same
append-only shape the vault already keeps.

Synapse is the first instance; Paperless and Immich have the same gap and
the same declaration will fit them.

Verified against the live rig rather than stubs: a real snapshot is
225 KB, restores into a scratch database with zero errors, and comes back
with all 251 events and 36 media rows. A restored media row resolves to
its file on disk at the right size and the right format. pg_dump takes
its own consistent view, so this runs against a live homeserver with
nothing stopped.

Three decisions worth knowing:

- Recordings stay out of the tarball. They ride the incremental archive
  path, so they are never re-copied and never subject to snapshot
  retention. A rotating snapshot can age out; an append-only archive
  cannot, and that is the right home for something irreplaceable.
- Snapshots run before the sync, so a dump is never newer than the media
  it references. Media without a row is a harmless orphan; a row without
  its media is a broken message.
- A failed dump does not stop the archives, because a database that will
  not dump must not cost you the photos. It does fail the run, because a
  backup that quietly captured less than it was asked to is the exact
  thing backups exist to prevent.

Local tarballs are pruned to the last 7. That cannot reach the vault: the
engine syncs with --ignore-existing and never --delete.
The Matrix media store holds the family's actual recordings. It was in no
backup at all, because adding it would have broken every backup on a new
install: Synapse does not create the media directory until somebody sends
the first photo, and preflight treated one missing source as fatal for
the whole run.

Preflight now tells two situations apart that used to look alike.

Nothing there yet — a directory missing or empty — is skipped and
reported, and the run continues. Nothing is at risk: the engine syncs
with --ignore-existing and never --delete, so an empty source copies
nothing and the vault keeps everything it already held.

Some files but far fewer than declared is the signal the guard exists
for, and it still aborts the whole run so a human looks first.

Skipped sources are reported in the run record, so an empty source shows
up in the summary and the Matrix notification rather than quietly not
being there. "skipped" was already a status the engine modelled and the
notifier rendered; nothing had ever produced it.

Only local_content is archived: the originals. Thumbnails regenerate from
them and url_cache is a cache of other people's web pages. famstack ships
with message and media retention disabled, so nothing in there is ever
purged, which is what makes it append-only.

Verified on the live rig, both branches: an uncreated media store skips
while documents still sync, and a source holding 3 of an expected 500
files aborts.
@arthware-dev arthware-dev changed the title feat(backup): back up the Matrix timeline, not just the recordings feat(backup): back up Matrix — the timeline and the recordings Sep 11, 2026
min_files was a constant a developer wrote in a manifest while guessing
at a household they would never see. It could only ever catch "dropped to
almost exactly zero", and only when the author happened to pick a non-zero
number. A library of 50,000 photos reduced to 11 sails straight past
min_files = 10 — precisely the disaster the check existed for.

Every source is now judged against what it held on the previous run, read
from the history the engine already writes. That baseline is
self-calibrating: it means the same thing whether a household has fifty
files or five hundred thousand, it needs no manifest field, and the case
above becomes unmissable.

The vault could not serve as the baseline even though it is right there.
It is a cumulative high-water mark, and a family deletes things over the
years, so source and vault drift apart legitimately until the comparison
is noise.

min_files is gone from every manifest and from the wire format, replaced
by a rolling flag the orchestrator sets. A rolling source is one pruned on
purpose — a snapshot staging area keeps a fixed window — so its shrinking
is normal operation rather than a loss. That distinction belongs to the
framework, which knows it, not to a stacklet author, who would have to
guess again.

The guard is deliberately loose at half of last run's count: it is looking
for catastrophe, not policing somebody's tidying. The canary remains the
separate, precise tripwire for "encrypted in place", which no count can
detect.

Verified on the live rig: a first run with no history syncs, 57 files down
from a recorded 50,000 aborts, and a rolling source shrinking to its
window passes.
The guide still said databases and config were not covered and pointed at
Time Machine. It now describes what a snapshot is and why a database
cannot be rsynced, lists what a Synapse snapshot contains and why the
signing key and homeserver.yaml travel with the dump, and gives the
restore sequence in the order that matters: database first, recordings
second, because media the database does not know about is harmless while
a message whose recording is missing is broken.

Also documents the source-shrink guard alongside the canary, and corrects
the Paperless downgrade note, which claimed the stacklet cannot do
databases at all.
A dump only restores into something compatible with what wrote it, and
the failure is not subtle: a Paperless 3.x database will not boot under
2.x, and there is no downgrade. Nothing reads this yet, and no restore
tooling exists. It is recorded now because it is the one part of a
snapshot that cannot be added afterwards, and every tarball taken without
it stays ambiguous forever.

Each manifest now carries the image, version label and digest of every
container in the stacklet, plus the Postgres server version. Live example:

    stack-messages-synapse  matrixdotorg/synapse:latest
                            1.160.0  sha256:1231c84d...
    stack-messages-db       postgres:16-alpine  sha256:cf78e766...
    postgres                16.15

The digest is the load-bearing field. The homeserver runs a `latest` tag,
which names a different image every month and nothing identifiable in
five years; the digest still names this exact image whenever somebody
comes back to it.

Containers are found by their compose project label rather than a list in
the manifest, so a stacklet cannot forget to declare a service it later
adds. A container without a version label records image and digest alone,
which is what Postgres does.

Version capture never costs the dump. Docker unreachable, a container
stopped, an image pruned: all recorded as empty and the database still
gets written.
The stacklet README and the framework reference both still described the
old contract. Three things were wrong, two of them from this branch.

`min_files` appears in every manifest example and has its own row in the
field table, including the advice to "keep low enough that fresh installs
don't trip it" — which is exactly the guidance that made it useless. The
field no longer exists; the engine derives its baseline from the source's
own previous run.

`[[backup.snapshot]]` was documented as "reserved, not yet implemented,
declare an archive section today". It ships. Both docs now carry the full
declaration, the ordering guarantee, and what the tarball records.

The README also still announced the stacklet as scaffold-only with hooks
raising NotImplementedError. The engine has been ported and in use for
some time; no NotImplementedError remains anywhere in the tree. Its
status section now says what is covered, what is not, and that restore is
deliberately still manual.

Adds a section on the two source guards, the canary and the shrink check,
including the note that the skip-when-empty behaviour depends on the
engine syncing with --ignore-existing and never --delete.
Adds non-negotiable 12: comments explain the code, not sell it. Literate
but neutral, with a before/after example.

The comments written in this branch had drifted into persuasive prose:
rhetorical contrasts, dramatic framing, closing lines that existed for
rhythm rather than information. This rewrites them across the snapshot,
voice and scribe modules and their tests, keeping the explanatory depth
and changing only the tone.
The discovery tests write their own manifest, so they verify the parser
against an example the test also wrote. Renaming a key in
stacklets/messages/stacklet.toml left them green, and only a live
snapshot would have shown it. That is how the namespacing change reached
a commit without unit coverage of the declaration it changed.

Adds two tests reading the real manifest through a temporary instance
directory. Confirmed they fail when the `container` key is misspelled.
An instance that has been backing up photos and documents for months
upgrades into a release that adds snapshots and a second archive. Two
ways that could go wrong, both now covered against the real engine and a
real APFS vault image.

The shrink check reads each source's previous count from history, and
every run recorded before this release predates that field. An absent
baseline is treated as no baseline, so the first upgraded sync proceeds
and records counts the next one can use.

The new sources are added without disturbing what is already on the
vault, which is immutable and cannot be rewritten.

Also narrows the baseline to sources that actually synced. A failed
source recorded a count of zero, which was ignored only because zero is
falsy; skipping any status other than "ok" states the rule instead.
@arthware-dev
arthware-dev merged commit 211ee15 into main Sep 11, 2026
1 check passed
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant