Backups#

Looking for the full disaster-recovery story? This doc covers only per-app bundles — Vaultwarden’s vault entries, Forgejo’s repos, Frigate’s camera footage, Home Assistant’s automation history. For the umbrella view that ties bundles together with your git remote (project shape) and your panic backup (encryption keys + credentials), see disaster-recovery.md . Three layers, restored in three different ways, each documented independently. The umbrella doc is the right starting point if you’ve never actually walked through a recovery before.

What Are They?#

Every self-hosted setup needs an answer to the question: “What happens if my server catches fire?” Backups are that answer. Each app you install knows what data is precious to it; PSW knows how to package that data into portable bundles that any other PSW can ingest, and how to ship those bundles wherever you want them stored — a USB stick, a NAS , an S3 bucket on the other side of the planet.

Think of it like moving house. Each app packs its own moving boxes. Some apps pack one box (a password manager), some apps pack several with different priorities (a media server packs a small “config” box and a much bigger “media files” box). The boxes are clearly labeled with what’s inside, what version of the app produced them, and what kind of personal data they contain. A separate piece of the system handles the actual moving — putting boxes on USB sticks, mailing them, or driving them to a deduplicating storage warehouse. Packing the box and shipping the box are two different jobs, deliberately separated so you can pick the right shipping method for each kind of data without changing how the apps themselves work.

The Two Layers#

Two cleanly separated pieces:

1. Backends — the packers (one per app)#

A bundle backend knows how to package one app’s data correctly. Vaultwarden packs its database + the /data directory exactly the way Vaultwarden’s own backup wiki recipe describes — meaning a bundle PSW produced is restorable into ANY Vaultwarden installation, not just another PSW. Forgejo (planned) will use its own gitea dump tool. PostgreSQL packs the entire cluster with pg_dumpall plus a CHECKPOINT for crash-consistency. Home Assistant packs its config + database AND knows how to restart itself cleanly after a restore.

You don’t write any of this. 80% of apps inherit a generic packer for free — the moment an app declares a backup: block in its meta.yml , PSW knows how to pack it as a database dump + tarred volumes. Apps where upstream offers a better tool override with one small Python file.

2. Transports — the shippers (one per shipping method)#

A bundle transport answers exactly one question: how do I get this packed-up bundle somewhere durable, and how do I get it back?

TransportWhat it actually isBest for
LocalDir“Just leave the box on the floor” — bundle directory at a path on your workstationQuick local backups before risky changes; the default for psw bundle export
Tarball“Wrap the box in shrink-wrap and hand it to me” — one .tar.gz filePutting on a USB stick, emailing to yourself, copying to a friend’s machine, proving cross-installation portability
Restic“Send it to a magic warehouse that notices duplicates” — encrypted, deduplicated, incrementalScheduled nightly backups (small daily deltas, weeks of history at minimal storage cost). Powered by restic under the hood, scheduled by Backrest
ZfsSend“Pump the data through a ZFS -to-ZFS pipe” — native incremental snapshot streamBig databases, fast crash-consistent snapshots, ZFS-native households. Only works when both ends run ZFS

Same packed box, different shipping method. A backup of Vaultwarden today as a Tarball lands as a single file you can put on a USB drive and restore on a brand-new PSW install of Vaultwarden. The same backup as a Restic snapshot lives in a content-addressed warehouse and dedupes against tomorrow’s snapshot; identical contents means tomorrow adds ~zero new bytes. The boxes are interchangeable; the trip differs.

Backing Up + Restoring Today#

The operator-facing CLI lives on the unified psw command — no separate tool, no remembered tricks. The verbs:

psw bundle export   <app>[:<bundle>]   [--transport=local_dir|tarball|...]   [--to=<path>]
psw bundle import   <app>[:<bundle>]   [--transport=...]                    --from=<path>
psw bundle list     <app>[:<bundle>]   [--transport=local_dir|restic]
psw bundle gc       <app>[:<bundle>]    --transport=local_dir|restic --keep=N [--dry-run]
psw bundle validate <app>[:<bundle>]   [--transport=...]                    --from=<path>
psw bundle inspect  <bundle-path>

Most operators only need the basics:

psw bundle export vaultwarden                          # produces backups/vaultwarden:default/<timestamp>/
psw bundle export jellyfin --transport=tarball --to=/tmp/jf.tar.gz
psw bundle import vaultwarden --transport=tarball --from=/tmp/vw.tar.gz
psw bundle list   vaultwarden                          # what's in backups/, newest first
psw bundle list   vaultwarden --transport=restic       # what's in the restic repo
psw bundle gc     vaultwarden --transport=local_dir --keep=7 --dry-run   # preview retention
psw bundle gc     vaultwarden --transport=local_dir --keep=7             # apply
psw bundle inspect /path/to/some-bundle                # reads the manifest, no state change
psw bundle validate vaultwarden --from=./bundle        # dry-run compatibility check

list shows newest first, with the identity column (a directory path or a restic snapshot id) you can paste back into --from= / --snapshot=. gc keeps the N newest per <app>:<bundle> namespace and removes the rest — jellyfin:config and jellyfin:media are accounted independently so a --keep=1 on bare jellyfin never sacrifices the only media bundle for a third copy of config. Tarballs are operator-managed (no list/gc — a tarball is one file you already know the path of); restic retention also runs on a schedule via Backrest’s forget policy, with psw bundle gc --transport=restic as the ad-hoc operator path.

The bundle name (:<bundle> part) only matters for apps that own multiple kinds of data. Jellyfin has two: jellyfin:config (small, irreplaceable, daily-backup-worthy — the default for psw bundle export jellyfin) and jellyfin:media (terabytes, opt-in, never restic). PostgreSQL has postgres:cluster for the whole-cluster safety dump. Most apps have only one bundle; you never need the suffix.

The honest restore story: psw bundle import <app> --from=<bundle> pulls a bundle off disk, verifies it (path-traversal checks, bomb defense, file-hash match against the bundle’s manifest), confirms it’s compatible with the deployed app version, then ingests it. Each app’s backend handles its own restoration logic — Home Assistant restarts itself and waits for /api/ to come back; PostgreSQL drops + recreates the target database before replaying the dump. Imports refuse loudly with a typed error when the bundle is incompatible (different app, schema-version drift, content-hash mismatch). Operator can override with --force-skew after reading the validate report.

Where to Keep Your Bundles#

A bundle is only useful if it survives whatever you’re recovering from. The two operator-facing transports each land somewhere by default — and neither default is a place you’d want to rely on for real disaster recovery:

TransportDefault locationSurvives psw deploy reset?Survives a reboot?
local_dir<project>/backups/<app>:<bundle>/<timestamp>/ inside your project folder❌ Lives inside the project — gets wiped along with everything else
tarballWherever you point --to=; common ad-hoc choice is /tmp/<app>.tar.gz✓ Outside the project/tmp is volatile on most Linux distros
resticThe shared Backrest repo at /data/repos/default on the observability LXCRestic repo dies with the project unless you also back the repo up off-box

The hygiene is on you. A working bundle on the same machine that just caught fire is no bundle at all. The two practical patterns that actually work:

  1. Off-box copy after every meaningful change. When you’ve added items to Vaultwarden, set up new SSO accounts, configured dashboards, etc., produce a tarball and copy it somewhere durable: a USB stick in a drawer, an encrypted cloud-drive folder, a friend’s NAS, the age key backup envelope. Bundles are tiny — most apps’ default bundle is well under 100 MB.
    psw bundle export vaultwarden --transport=tarball --to=~/backups-offsite/casa-vault-$(date -I).tar.gz
    # then physically move the file off-box
  2. Scheduled Restic + off-site Restic repo. This is what psw bundle export --transport=restic is meant to deliver, paired with Backrest ’s scheduled forget policy. The repo itself needs to live somewhere off-box (S3, Backblaze B2, SFTP) — currently the repo is on-box only; multi-repo / off-site routing is planned in docs/plans/improvements/backrest-plan-import.md §“Step 3”.

A note on encryption. tarball and local_dir bundles are plaintext on disk — anyone with filesystem access to the file plus your master password (Vaultwarden) or your DB content (other apps) has your data. If you’re putting bundles on a USB stick or cloud drive, treat the storage as “anywhere your master password isn’t safe” and pick storage you trust. restic bundles are encrypted at rest with a SOPS-managed password — that’s the right transport for “I want to put my backup on B2 without trusting B2”.

A note on cross-project portability. Tarballs are designed to round-trip across projects — the bundle’s upstream_format field (e.g. "vaultwarden-wiki-v1") is the explicit portability contract, exercised in CI by test_bundle_proving.py. Restic bundles are project-local: the repo is encrypted with a project-scoped SOPS secret, so project P2 with a different age key cannot decrypt P1’s restic repo. For disaster recovery across projects (or across a project rebuild), tarball is the shape that works. See disaster-recovery.md for the umbrella story — bundles are one of three layers (project shape from git, secrets from the panic backup, per-app data from these bundles), and “I survived my house burning down” requires all three.

What’s Already Automated vs. What You Configure#

PSW manages the packing (every app declares what it backs up) and the plumbing (operator commands, transports, integrity checks, lifecycle hooks). What you decide:

  • Where backups land — every transport’s --to= is yours. Restic transports use a shared restic repository (with a single password) you point at a local pool, SFTP target, S3 bucket, Backblaze B2 , etc.
  • Schedules — when scheduled backups land (see “What’s Coming” below), each app’s bundle has its own cron expression. Critical-priority data daily, normal weekly, big media-bundle archives monthly.
  • Retention — restic’s “forget” rules (keep N daily, M weekly). Configured in Backrest’s UI per repo.
  • Repository credentials — restic passwords, S3 keys, SFTP credentials. PSW never sees them; you enter them in Backrest, or PSW’s SOPS -encrypted secrets store the restic password for the local default repo.

PSW’s stance on this split: where you store your backups is the one decision nobody else can make for you. Plumbing should be invisible; storage policy should be deliberate.

What’s Coming (Not Working Yet)#

The architecture committed to in docs/plans/backup-restore/vision.md lists four transports. LocalDir, Tarball, and Restic ship today and have been exercised (LocalDir + Tarball end-to-end on the from-scratch E2E project; Restic in unit tests + design-ready for the deferred live round-trip). ZfsSend ships as a permanent capability awaiting its first adopter — the transport stays in the framework regardless (decision locked in docs/plans/zfs_send/vision.md ), but no backend overrides zfs_dataset(ctx) yet, so every invocation refuses cleanly per CLAUDE.md no-silent-fallback. jellyfin:media is the designated first adopter.

Remaining work:

  • Cross-project disaster-recovery test on real hardware — the proving test from vision.md §“The proving test” needs two genuinely independent boxes (different domains, different SOPS keys, different DB passwords). The recipe is automated end-to-end in CI on synthetic projects, but a hand-walkthrough on real Proxmox hardware is the final stamp.
  • Live restic round-trip on real infrastructure — the from-scratch E2E project skipped Backrest, so the restic transport is unproven on a live deploy. Tracked in docs/plans/backrest-restic/ .
  • ZfsSend first adopter — when multi-TB ZFS-resident data needs a backup story restic can’t serve well (already-compressed bulk like video / photos that defeats restic’s chunker), the first zfs_dataset(ctx) override + two-pool integration test land. Tracked in docs/plans/zfs_send/next-session.md .
  • Off-site repo routing — Restic today targets the project’s single shared Backrest repo. Multi-repo support (route critical bundles to S3, normal to local pool) is tracked in docs/plans/improvements/backrest-plan-import.md §“Step 3”.

The retired psw-ops snapshot standalone tool is gone — its replacement path is psw bundle export --transport=zfs_send, which becomes operational the moment the first backend opts in.

Notifications When Something Goes Wrong#

A silent backup failure is the worst kind. Once you deploy ntfy on your setup, the backrest.ntfy integration reconciler automatically:

  1. Reads Backrest’s /config/config.json
  2. For every repository configured, injects a hook on the CONDITION_SNAPSHOT_ERROR and CONDITION_ANY_ERROR conditions
  3. The hook uses Shoutrrr to fire a notification at the psw-backrest ntfy topic
  4. Restarts Backrest so the change takes effect

Subscribe your phone to that topic and you get a ping the moment a scheduled backup fails — no dashboard to stare at, no email to miss.

What Gets Backed Up, Really?#

A quick tour of the catalog and what their backup: blocks declare:

AppBundle name(s)Database?VolumesFormat
Vaultwardendefaultyes/data (critical), excludes icon_cache/vaultwarden-wiki-v1 — matches upstream backup wiki
PostgreSQLclusterno*/var/lib/postgresql/data (critical)psw-pg-cluster-v1pg_dumpall after CHECKPOINT
Forgejodefaultyes/var/lib/gitea (critical), excludes gitea/sessions/, gitea/queues/gitea-dump-v1 — wraps gitea dump --type tar
Sonarrdefaultyes/config (critical), excludes logs/, MediaCover/arr-rest-v1 — REST-API consistency-trigger + pg_dump
Radarr / Lidarrdefaultyes / no/config (critical), excludes logs/, MediaCover/psw-pg-tar-v1 (generic — drop a four-line subclass to upgrade them to arr-rest-v1)
Jellyfinconfig (default)no/config (critical), excludes cache/, transcodes/, log/jellyfin-config-v1
Jellyfinmedia (opt-in)no/media (low priority)jellyfin-media-v1 — never restic; ZFS-send / tarball only
Home Assistantdefaultyes/config (critical)homeassistant-pg-tar-v1 — restart + health-check on import
Grafanadefaultyes/var/lib/grafana (critical)psw-pg-tar-v1 (generic)
Prometheusdefaultno/prometheus (normal)psw-pg-tar-v1 (generic)

*postgres:cluster is not a per-app DB dump — it’s the whole-cluster safety net. Each consumer app (Vaultwarden, Forgejo, etc.) gets its own per-database pg_dump in its own bundle, and those are what cross-project disaster recovery actually exercises. The cluster bundle is for postgres-cluster-corruption recovery — restoring it into a different PSW project would corrupt the destination’s role/grant state.

The pattern is consistent: keep what you can’t recreate, skip what regenerates for free, package it the way the upstream tool itself blesses if there’s an upstream tool.

Key Ideas#

  • Bundles are portable — a backup of Vaultwarden produced by PSW is restorable into any Vaultwarden installation, not just another PSW. The bundle’s manifest (upstream_format: "vaultwarden-wiki-v1") is the contract.
  • Two layers, never collapsed — backends pack, transports ship. New transports (off-site backups, ZFS-native streams) plug in without rewriting any backend; new apps with weird upstream backup tools plug in without rewriting any transport.
  • 80% of apps need zero per-app code — declaring a backup: block in meta.yml is enough. PSW infers the right bundle behavior. Apps with weird upstream backup tools get a tiny bundle.py override.
  • Restore is loud-and-typed, not silent-and-best-effort — incompatible bundles refuse, with a clear reason. --force-skew is the only escape hatch and you have to type it yourself.
  • One CLI, no surprisespsw bundle export / import / list / gc / validate / inspect on the unified psw command. The legacy psw-ops backup / psw-ops restore commands have retired; the architectural intent of “only psw” is honored.
  • Where you store backups is your decision — PSW does the packing; you point the chosen transport at the storage you trust.

For the architectural detail (manifest schema, security hardening, the proving test that gates the framework’s “done” status), see docs/plans/backup-restore/vision.md .