timeshift-btrfs-sync backs up Timeshift Btrfs snapshots and can restore either one backup or a complete UUID-validated backup chain into the configured Timeshift repository. It can pull from or restore to another machine over SSH, or operate locally on the same machine. All restore directions use the same Btrfs, metadata, validation, and safety logic; only the selected backup and Timeshift endpoints change.
⚠️ AI-assisted / vibe-coded experimental software. Use at your own risk.
This project is AI-assisted / vibe-coded software created as a hobby project. It has not been professionally audited and may contain bugs, unsafe behavior, data-loss issues, security problems, or incorrect assumptions.
You are responsible for reviewing the code, testing it in a safe environment, making backups, and understanding what it does before using it on real data. The author is not responsible for damage, data loss, broken systems, security issues, or other problems caused by using this software.
This application can perform destructive operations, including deleting Btrfs subvolumes, snapshots, and backup data. Always test with dry-runs first, check the generated plans, and keep a separate working backup.
MIT License. See LICENSE.
source.mode is used only by normal sync/source commands: ssh reads Timeshift snapshots through SSH and local reads them on the current machine. Restore direction is selected separately by [restore] mode: local, ssh, or ssh-target.
timeshift-btrfs-sync is a destination-pull backup tool for Timeshift Btrfs snapshots. It runs on the backup/destination machine, connects to the source over SSH by default, or uses local source mode on the same machine, and transfers Timeshift snapshots with btrfs send / btrfs receive.
It supports full and incremental backup sends, Timeshift snapshot discovery, copying each snapshot date’s shared Timeshift info.json, writable source snapshots through a read-only send cache, restoring one backup or a full post-common backup chain to Timeshift’s native layout, safe paired retention pruning, optional automatic Timeshift on-demand snapshots, split logs, MQTT notifications, and email notifications with optional log attachments.
Normal sync and restore deliberately interpret the configuration differently:
| Command | Timeshift side | Backup side | Setting that selects SSH |
|---|---|---|---|
sync with source.mode = "local" |
Local | Local | SSH is not used. |
sync with source.mode = "ssh" |
SSH host | Local | [source].mode and [ssh]. |
restore with restore.mode = "local" |
Local | Local | SSH is not used. |
restore with restore.mode = "ssh" |
Local | SSH host | [restore].mode and [ssh]; this is pull restore. |
restore with restore.mode = "ssh-target" |
SSH host | Local | [restore].mode and [ssh]; this is push restore. |
sync never writes the backup destination over SSH. destination.target_root is always local during sync. Therefore a pull-restore profile with restore.mode = "ssh" must not be used to create the backup: during restore that same path is read on the SSH host, but during sync it would have been written locally. The app refuses this command/config combination instead of silently creating a different repository.
For a reversible remote round trip, run both commands on the backup machine with one SSH host:
[source]
mode = "ssh"
[restore]
mode = "ssh-target"
[ssh]
host = "the-timeshift-source.example.lan"This means: pull Timeshift snapshots from that SSH host into the local backup, then push the local backup back to that same SSH Timeshift host. For pull restore, use a separate restore-pull profile on the Timeshift machine; its SSH host is the backup machine.
Three complete commented config profiles are packaged: config.example.toml for normal sync/local restore, config.restore-pull.example.toml for SSH-backup-to-local-Timeshift restore, and config.remote-roundtrip.example.toml for SSH Timeshift source → local backup → push restore back to the same SSH Timeshift host. Generate one with ts-btrfs init-config --profile ....
The safe defaults are intentionally conservative:
default_dry_run = truepreviews changes unless--runis passed. In strict dry-run mode the app does not prepare the destination, create lock/state directories, runbtrfs receive, or delete/prune snapshots.- Destination pruning only deletes when
--run --yes-deleteis used. - Incremental parents are verified with Btrfs UUID metadata before use.
- Automatic source-side manual snapshot creation requires source identity and sync-parent viability checks before Timeshift is changed.
- Restore refuses to overwrite an existing Timeshift date, intersects the configured physical Timeshift and backup timestamp trees newest-first, requires real Btrfs payloads on both sides, prefers exact state/cache Btrfs lineage, securely falls back to matching same-date
info.jsonsys-uuid/type, always creates an exact receiver-side full seed before later incrementals, exposes writable CoW snapshots in Timeshift’s native layout, and commits only after every payload and metadata check succeeds. - Normal/user-created Timeshift on-demand snapshots are not pruned unless explicitly enabled.
- The app does not manage destination Btrfs compression; mount the receiving Btrfs filesystem/subvolume with compression enabled if you want compressed destination storage.
For normal backup, prune, cache cleanup, and Timeshift manual-snapshot operations, the source machine needs passwordless sudo only for Btrfs and Timeshift:
ts-btrfs-sync-user ALL=(root) NOPASSWD: /usr/bin/btrfs *
ts-btrfs-sync-user ALL=(root) NOPASSWD: /usr/bin/timeshift *
This is enough because Timeshift listing/creation, Btrfs send, Btrfs metadata checks, read-only cache creation, and source send-cache cleanup use those two commands. Per-snapshot Timeshift info.json files are read with ordinary non-sudo cat; the SSH/source user therefore needs normal path traversal and file read permission.
The restore command additionally scans physical timestamp directories and control files, then creates and renames an ordinary Timeshift date directory and writes its regular info.json. The simplest supported setup is to run a local restore as root. For an unprivileged restore, grant narrow source-side passwordless sudo for /usr/bin/test, /usr/bin/ls, /usr/bin/base64, /usr/bin/mkdir, /usr/bin/tee, /usr/bin/chmod, /usr/bin/mv, /usr/bin/rm, and /usr/bin/rmdir, plus the configured Btrfs and Timeshift commands. The backup-side endpoint also needs test, ls, and base64 through destination.sudo for its read-only physical scan. For SSH-target restore these source permissions must exist on the remote Timeshift account; for SSH pull the backup scan permissions must exist on the remote backup account. The app prefixes individual commands and does not require passwordless sh or bash; granting a general shell is not recommended.
The app runs on the backup/destination machine, but info.json is read on the source machine by the unprivileged account configured as [ssh].user. This is the account the destination uses for the SSH connection. It is not necessarily the local user or root account running ts-btrfs on the destination.
The combined inventory records the effective source account name and UID without opening another SSH connection. If a required info.json cannot be read, the terminal and normal log error include a line like:
remote SSH source account used by this destination: btrbk-source (uid 1001)
That exact account must be able to:
- traverse every parent directory leading to
source.snapshot_root(x/search permission); - list
source.snapshot_root(r-x), because the combined inventory enumerates the timestamp directories in one shell loop; - traverse each timestamp directory;
- read
<source.snapshot_root>/<date>/info.json.
A file can be mode 0644 and still be unreadable when one parent such as /media/<desktop-user> is mode 0750. Root may read it locally while the unprivileged SSH account receives Permission denied. Diagnose the first blocked component on the source host with:
sudo -u btrbk-source -- namei -l \
/path/to/timeshift-btrfs/snapshots/<date>/info.jsonFor a reliable unattended backup source, a privileged administrator should mount the Btrfs filesystem at a stable system path through /etc/fstab instead of relying on a private desktop mount below /media/<user>. Example for the Btrfs top-level tree:
sudo install -d -m 0755 /mnt/OS-Root
sudo blkid
sudoedit /etc/fstabExample /etc/fstab entry:
UUID=<BTRFS-FILESYSTEM-UUID> /mnt/OS-Root btrfs defaults,subvolid=5 0 0
Mount it and grant the configured SSH account only the access it needs:
sudo mount /mnt/OS-Root
# Search-only permission through private parent directories.
sudo setfacl -m u:btrbk-source:--x /mnt/OS-Root
sudo setfacl -m u:btrbk-source:--x /mnt/OS-Root/timeshift-btrfs
# The inventory must be able to list the snapshot dates.
sudo setfacl -m u:btrbk-source:r-x \
/mnt/OS-Root/timeshift-btrfs/snapshots
# Confirm the exact file is readable as the SSH account.
sudo -u btrbk-source -- cat \
/mnt/OS-Root/timeshift-btrfs/snapshots/<date>/info.jsonReplace btrbk-source with the name printed by the application and use its printed UID when checking ownership or ACL results. The mount does not have to be owned by the backup account; it only has to be accessible to that account by name/UID through normal mode bits or a narrow ACL. Btrfs normally keeps Unix ownership in filesystem metadata, so do not rely on FAT/NTFS-style uid= or gid= mount options to remap ownership. After changing the mount path, update source.snapshot_root and source.cache_root in the config.
destroy-leftovers --delete-source keeps the source sudoers model narrow. On the source host it uses passwordless sudo btrfs only for Btrfs metadata and subvolume deletion. It does not require passwordless find, test, rm, mkdir, or cat.
Source cache payloads and timestamp containers are Btrfs subvolumes. Cleanup deletes child cache subvolumes first and then deletes the timestamp parent with btrfs subvolume delete. The app does not use ordinary-directory cleanup as a fallback. If a configured cache path is an ordinary non-empty directory, cleanup stops and requires manual inspection.
The destination target_root is the backup job folder. The app creates and owns:
<target_root>/snapshots/ Btrfs snapshots-root subvolume
<target_root>/snapshots/<date>/ Btrfs date subvolume
<target_root>/snapshots/<date>/info.json shared Timeshift control file
<target_root>/snapshots/<date>/@ received root subvolume, when configured
<target_root>/snapshots/<date>/@home received home subvolume, when configured
<target_root>/.ts-btrfs-sync/ state.json, lock file, logs
Timeshift uses one info.json for the whole snapshot date. It sits beside @ and optional @home; there is not a second combined metadata file to create. The app captures that one file once and writes it once after every subvolume configured for the date has completed. This works with subvolumes = ["@", "@home"], subvolumes = ["@"], or subvolumes = ["@home"].
The destination target_root, its managed snapshots/ child, and every snapshots/<date> container must be Btrfs subvolumes. If target_root is missing and destination.create_target_root = true, the app creates it with btrfs subvolume create. Missing snapshots/ is also created with btrfs subvolume create, but unlike state, lock, and optional log helpers it never falls back to mkdir. Each date container is created and exact-probed as a Btrfs subvolume before btrfs receive. Existing ordinary date folders, files, or symlinks directly below snapshots/ are refused as an unsupported layout and must be inspected and moved or removed manually. A bulk destination index miss is not enough to classify a date as ordinary: the app first runs an exact btrfs subvolume show probe and accepts/adds a real subvolume to the current index.
state.json records successfully received snapshots and the metadata needed for incremental sends. Do not manually delete state.json while a job is running. Use the guarded clear-state command when you intentionally want to remove the configured state file after a failed transfer or before a controlled state-recovery run. Do not delete only snapshots/ while keeping the corresponding state file.
State schema version 3 stores every managed path relative to the configured root that owns it:
{
"source_path": "2026-07-15_05-00-02/@",
"send_path": "2026-07-15_05-00-02/@",
"send_path_kind": "source-cache",
"parent_source_path": "2026-07-15_04-00-02/@",
"parent_source_path_kind": "source-cache",
"destination_path": "snapshots/2026-07-15_05-00-02/@"
}source_path is always relative to source.snapshot_root. send_path is resolved under source.cache_root when send_path_kind = "source-cache", or under source.snapshot_root when send_path_kind = "timeshift-original-readonly". parent_source_path follows the same rule through parent_source_path_kind. destination_path remains relative to destination.target_root.
This lets you move or remount source.snapshot_root, source.cache_root, or the whole destination target, then update those roots in the config. State paths resolve below the new locations. Btrfs UUID identity is still required before a moved send-cache or Timeshift path can be used as an incremental parent. state.json must use schema version 3 and root-relative managed paths; unsupported versions, absolute stored paths, missing ownership kinds, and mismatching snapshot/subvolume suffixes are rejected.
During sync and before standalone prune, mutable Timeshift metadata for already-synced snapshots is refreshed from the latest timeshift --list. This updates snapshot-level tags, comment, created, and path in state.json without re-sending data and without changing Btrfs UUID, parent-chain, send-path, destination-path, or status fields. This lets retention follow Timeshift when it later promotes or changes flags such as O, H, D, W, or M. The metadata refresh uses the fast Timeshift list path and does not run btrfs subvolume show for every already-synced snapshot.
If state.json is missing or empty while destination snapshots already exist, sync tries a conservative state recovery before creating a manual snapshot or sending data. It scans destination snapshots and compares each destination subvolume's Received UUID with the UUID of the matching source Timeshift subvolume or an existing read-only source-cache subvolume. Only exact UUID matches are adopted into in-memory state and, during a real run, written back to state.json. Names alone are never trusted. If an existing destination subvolume cannot be adopted, the app refuses to delete it as an incomplete receive because it may be a valid backup from a missing state file.
A full reset means deleting both the received snapshot subvolumes and .ts-btrfs-sync/. Use destroy-leftovers for a complete app-owned reset. It deletes nested Btrfs subvolumes deepest-first and does not recursively remove ordinary non-empty backup trees.
The backup layout and Timeshift's native source layout are intentionally different:
Backup destination:
<target_root>/snapshots/<date>/ Btrfs date-container subvolume
├── info.json regular file
├── @ read-only received Btrfs subvolume
└── @home read-only received Btrfs subvolume, when configured
Timeshift repository after restore:
<source.snapshot_root>/<date>/ ordinary directory
├── info.json exact original Timeshift file
├── @ writable Btrfs subvolume
└── @home writable Btrfs subvolume, when configured
The command has two restore selections:
restore --snapshot <date>restores one selected backup.restore --allfinds the newest proven common snapshot and restores every newer backup oldest-to-newest.
Add --create-pre-restore-snapshot to create one Timeshift on-demand/tag O snapshot of the system being restored before any hidden receive directory or Btrfs stream is started:
ts-btrfs restore --config ./config.toml \
--all \
--create-pre-restore-snapshot \
--dry-runIn a real run the snapshot is created only after the restore plan, OS identity, target-path checks, and typed confirmations have succeeded. The app then re-reads timeshift --list, identifies exactly one newly created timestamp, and exact-checks every configured payload such as @ and @home with Btrfs metadata. Failure aborts before transfer.
The safety snapshot always runs through the configured Timeshift source endpoint:
- local backup → local Timeshift: created locally;
- local backup → SSH Timeshift: created on the remote Timeshift machine;
- SSH backup → local Timeshift with
[restore] mode = "ssh": created locally.
It is never created on destination.target_root or on the SSH backup host. The fixed comment is TimeShift-BTRFS-Sync pre-restore safety snapshot. It is intentionally left in Timeshift if the later restore fails, providing a rollback point for the pre-restore system state. The [manual_snapshot] section controls automatic snapshots before sync; it does not control this restore-only flag.
Restore can reverse the normal network direction without a second restore implementation. In pull mode the backup repository is remote and the Timeshift repository is local:
SSH backup host: destination.target_root/snapshots/<date>/@
btrfs send over SSH
↓
Local machine: source.snapshot_root/<date>/@
local btrfs receive
Generate the complete restore-only profile instead of building it from a shortened snippet:
ts-btrfs init-config \
--profile restore-pull \
--path ./config-restore-pull.tomlThe generated file includes every supported top-level, restore, SSH, source, destination, stream, retention, manual-snapshot, MQTT, and mail setting with comments. Its [ssh] section includes key authentication, identity_passphrase/identity_passphrase_file for encrypted private keys, optional remote-account password/password_file, plus port, cipher, compression, ControlMaster, strict host-key checking, timeouts, keepalives, and jump-host examples.
The generated pull profile sets [restore] mode = "ssh", so destination.target_root and state_file are interpreted on the SSH backup host. lock_file, source.snapshot_root, and source.cache_root remain local to the machine running the restore; neither Timeshift path nor the restore lock is probed on the backup host. source.mode is not used to choose restore direction. Use a separate restore config so later normal sync, prune, and destroy-leftovers runs do not accidentally interpret remote restore paths as local backup paths.
Dry-run and real examples:
ts-btrfs restore --config ./config-restore-pull.toml \
--all \
--create-pre-restore-snapshot \
--dry-run
ts-btrfs restore --config ./config-restore-pull.toml \
--all \
--create-pre-restore-snapshot \
--run \
--i-understand-this-modifies-timeshiftIf a local Timeshift repository is accidentally selected as the backup, the app now explains that Timeshift date folders are correctly ordinary directories and points to this transport setting instead of presenting the layout as corrupt.
The remote SSH account needs narrow passwordless permission through destination.sudo for the configured Btrfs command plus read-only test, ls, and base64 operations used to inventory backup timestamp directories, info.json, and state.json. The scanner prefixes those individual commands; it never runs a privileged shell. The account does not need remote lock-file write permission or flock. The local side needs the normal restore permissions for btrfs receive, Timeshift, ordinary date directories, info.json, and the configured local lock_file. The local restore lock is held until all streams and verification complete.
Restore parses Timeshift metadata before changing the source repository. It uses:
sys-uuidas the root-filesystem identity recorded when that snapshot was created.type, which must identify Btrfs snapshots.sys-distroas diagnostic context only, because an in-place distribution upgrade can change that text without creating a different root filesystem.
The comparison intentionally ignores snapshot-specific fields that legitimately change between snapshots, including H/D/W/M/O/B tags, comments, creation time, file counts, Timeshift app version, live status, and Btrfs statistics.
The original info.json is restored unchanged. Its sys-uuid is the stable Timeshift filesystem/OS identity recorded for that snapshot. Restore compares sys-uuid and type for the same timestamp as a secure fallback when exact state/cache Btrfs lineage is unavailable. It never compares the whole JSON object: tags, comments, creation time, counters, and other mutable fields may differ. Exact Btrfs lineage remains the stronger proof when available.
Every backup selected by --all must contain one consistent sys-uuid and snapshot type. A mixed backup set is refused. The backup provenance is compared with the readable info.json files in the current Timeshift repository. When no exact UUID common parent exists and no metadata match can be proven, dry-run prints a cross-OS warning and a real restore requires:
--allow-os-identity-mismatch
followed by this exact typed sentence:
I UNDERSTAND THIS BACKUP MAY BELONG TO ANOTHER OS
For restore --all, restore first intersects the timestamp directories physically present below the configured source.snapshot_root and destination.target_root/snapshots. It considers candidates newest-to-oldest. timeshift --list contributes tags, comments, and diagnostics, but it is not the authority for physical timestamp existence.
A candidate is usable only when every configured payload such as @ and @home is a real Btrfs subvolume on both sides. Restore then accepts the newest candidate proven by either of these methods:
- Exact Btrfs lineage proof: a completed
state.jsonrecord links the current Timeshift payload UUID tooriginal_source_uuidand the backup payloadReceived UUIDtosend_source_uuid; or the retained read-only cache proves current Timeshift UUID → cache Parent UUID and cache send identity → backup Received UUID. A natively read-only Timeshift payload may match the backup Received UUID directly. - Same-date Timeshift identity proof: when exact state/cache lineage is unavailable, the same physical timestamp exists on both sides, all configured payloads are Btrfs subvolumes, and that timestamp's source and backup
info.jsonfiles have the same stablesys-uuidandtype. Mutable tags, comments, creation time, statistics, and Timeshift version are deliberately ignored.
The second method is safe for choosing an already-present timestamp because restore does not use the visible Timeshift payload as the receive parent. It full-receives the exact backup copy of the common timestamp into a hidden receiver-side chain before sending newer incrementals. A matching date alone, a repository-wide info.json match from another date, or names in state.json without live payloads are never accepted.
state.json is therefore preferred evidence, not a mandatory dependency. Missing, unreadable, symlinked, malformed, or unsupported state is never trusted; restore records the validation warning, uses empty state for planning, and continues only if independent retained-cache or matching same-date info.json proof succeeds. destroy-leftovers --delete-destination may intentionally remove the destination and its state while keeping source.cache_root; a later fresh backup can be matched through exact retained-cache lineage. If cache/state evidence is unavailable, matching same-date info.json identity still permits the newest physically shared timestamp to be selected.
The read-only repository scan uses the configured privilege prefixes to run only test, ls, and base64 for directory/type/control-file inspection. It never grants or invokes a privileged shell. Local, SSH pull, and SSH-target restore use the same candidate and proof rules.
If the common timestamp is already the newest backup, there is nothing to restore. If no candidate can be safely confirmed, --all prints the newest failed candidate diagnostics and refuses a real full-chain restore unless --allow-no-common-parent and the stronger typed confirmations are supplied. The repository-wide info.json OS-identity warning remains separate and may require --allow-os-identity-mismatch when no exact proof overrides it.
Receiver-side full seed and incremental chain
Scan report · 2026-10-12
- ✓ Prohibited terms or links
- ✓ Repository eligibility
- ✓ slopscore.md paperwork
- ✓ Content policy
- ✓ Risk review — +10 owner has 0 followers
From the balcony · 0 of 4 clapped
Crusoe, Schnitzel, Cap'm Slop and Princess read it and passed. Their reasons are on the balcony, with every other verdict.
Critics are accounts on this site with no GitHub account behind them. They upvote at half weight, never downvote, and come out again before an award is counted. Who they are.
0 comments
log in to comment.