Backup & Restore¶
How to back up the OpenCCU-Loom daemon's own state, restore it, and keep the encryption key together with the data so secrets survive the round-trip.
Who this page is for
Operators responsible for disaster recovery and migrations. This page covers the daemon's state (its database and key). Backing up the CCU itself is a separate, CCU-side operation, exposed over the REST API and described at the end.
Before you upgrade
Run openccu-loom backup create before upgrading the daemon to a new version. Every schema migration under internal/store/sqlite/migrations/ carries a Down block, but the daemon only ever runs goose.UpContext at startup — there is no backup rollback or migrate down subcommand exposed to operators. If a migration or the new version misbehaves, the only supported recovery path is openccu-loom backup restore from a backup taken before the upgrade, not an automatic downgrade.
What to back up¶
OpenCCU-Loom keeps all persistent state in its data directory (data_dir, default ./var). The two files that matter for a full restore are:
| File | Contents | Why it matters |
|---|---|---|
<data_dir>/openccu-loom.db | SQLite database: centrals, config sections, users/tokens, sessions, paramset caches, audit log, Matter fabrics | the daemon's entire runtime state |
<data_dir>/secret.key | AES-256 master key (auto-generated, mode 0600) | decrypts the secret-classed values stored in the database |
Back up secret.key together with the database
Secret-classed fields (CCU passwords, API tokens, OIDC client secret, MQTT password, Matter passcode) are stored encrypted with the prefix enc:v1:. If you restore the database without the matching secret.key, those values cannot be decrypted and the daemon will log encrypted value but no master key available.
If you provide the master key through OPENCCU_LOOM_SECRET_KEY instead of the key file, back up that value securely instead — it plays the same role.
The live WAL/SHM sidecar files (openccu-loom.db-wal, openccu-loom.db-shm) do not need to be backed up; the CLI takes a clean, checkpointed snapshot.
Using the backup CLI¶
The daemon binary ships a backup subcommand with create and restore. It snapshots the database with VACUUM INTO (a clean, WAL-checkpointed copy) and packages it as a gzipped tar archive with a manifest.json carrying per-file SHA-256 sums.
Create¶
| Flag | Default | Purpose |
|---|---|---|
--config | — | path to config.yaml (resolves data_dir; without it, ./var is used) |
--out | backup-<hostname>-<utc>.tar.gz | output archive path |
--include-secrets | off | adds a secrets/ placeholder note (env-resolved secrets are not stored) |
--json | off | emit a single-line JSON result for scripting |
The archive contains state/openccu-loom.db (the VACUUMed snapshot), any other files under data_dir except secret.key, optionally the config.yaml you passed, and manifest.json.
The archive is NOT self-contained for decryption
backup create deliberately skips secret.key while walking data_dir — bundling the at-rest master key alongside the ciphertext it protects would let anyone who steals the archive decrypt it, defeating the point of encrypting it. When a master key is available, the whole archive is instead sealed with AES-256-GCM using that key on the way out.
This means secret.key (or the OPENCCU_LOOM_SECRET_KEY value, if that's how you provision it) must be preserved out-of-band, separately from the archive. Without it you cannot open the archive on restore, and even after a successful restore you cannot decrypt the enc:v1:-prefixed secret fields inside the database. backup create prints a one-line reminder of this to stderr on every run.
Secrets that you supply only through environment variables (e.g. OPENCCU_LOOM_MQTT_PASSWORD) are never written to the database or the archive either — re-supply those env vars before starting the restored daemon.
Restore¶
Stop the daemon first, then:
openccu-loom backup restore --config /etc/openccu-loom/config.yaml backup-host-20260608T120000Z.tar.gz
| Flag | Default | Purpose |
|---|---|---|
--config | — | path to config.yaml; also receives an extracted config.yaml if present |
--data-dir | from --config or ./var | override the restore target |
--force | off | overwrite an existing openccu-loom.db, and accept a backup whose database schema is newer than this binary supports |
Restore validates every file's SHA-256 against the manifest, writes to a staging directory, then atomically renames the database into place. If openccu-loom.db already exists it refuses unless --force is given. Start the daemon again after a successful restore.
Restore also compares the schema version stamped in the backup's manifest with the newest schema this binary knows. A backup taken by a newer daemon is refused with backup schema version N is newer than this binary supports (M); restore it with a daemon at least as new as the one that took it. --force overrides this check too — the restored database then carries a schema this binary was not built for, so use it only when you know why. A backup without a schema stamp is accepted as before.
Restore also clears the old WAL/SHM sidecars
A database restored next to the previous database's openccu-loom.db-wal / -shm would not be the database you restored: SQLite recovers a write-ahead log it finds beside a database file on the next open and replays the old pages into the new file. Restore therefore moves those sidecars aside together with the database, in the same all-or-nothing set — a failed commit puts the original database and its sidecars back together, a successful one discards them. You do not have to delete them by hand, and you must not copy them back in afterwards.
A scheduled backup (cron)¶
# /etc/cron.d/openccu-loom-backup
# nightly at 03:15, keep archives under /var/backups/openccu-loom
15 3 * * * openccu openccu-loom backup create \
--config /etc/openccu-loom/config.yaml \
--out /var/backups/openccu-loom/backup-$(date -u +\%Y\%m\%dT\%H\%M\%SZ).tar.gz
Prune old archives with your usual retention tooling (e.g. find /var/backups/openccu-loom -name 'backup-*.tar.gz' -mtime +14 -delete).
Docker volumes¶
Persist the data directory on a named volume so the database and secret.key survive container recreation, and run the backup against that volume.
Pin the master key in containers
For ephemeral container filesystems, set OPENCCU_LOOM_SECRET_KEY (a base64 32-byte key from openssl rand -base64 32) so the encryption key does not depend on an auto-generated file inside the volume. Keep that value in your secrets manager.
Exporting just the configuration¶
If you want to migrate configuration (sections + centrals) rather than the whole database, use openccu-loom config export / import. The export omits password hashes and token secrets; user and token rows are skipped on import for safety. This is the right tool for moving config between instances, not for disaster recovery.
openccu-loom config export --config /etc/openccu-loom/config.yaml --out config-dump.json
openccu-loom config import --config /etc/openccu-loom/config.yaml config-dump.json
The redacted round-trip¶
By default the export is redacted: every secret-classed leaf (CCU password, MQTT password, OIDC client secret, Matter passcode, …) is withheld — written as JSON null, with an empty password_plain on each central — and the document is stamped "redacted": true. Pass --include-secrets to export the cleartext instead; that file then carries live credentials and belongs in a secrets store, not in a chat window.
Importing a redacted document keeps the credentials already stored in the target database. The import reads the stored values first and merges them into every withheld leaf, so re-importing your own export cannot silently null out the daemon's own credentials. Two consequences worth knowing:
- A redacted export cannot transfer secrets to a fresh instance. Importing one into an empty database leaves those fields empty — set them in the SPA afterwards, or export with
--include-secrets. - Clearing a credential is not something an import can express. A withheld leaf means "unchanged", not "delete"; clear it on the target instance instead.
--replace deletes the stored sections and centrals before writing, but the merge still applies: the stored secrets are snapshotted before the delete, so a redacted --replace import does not lose them either. Use --dry-run to see how many sections and centrals a document would touch before it writes anything. import prints a reminder to stderr whenever it is fed a redacted document.
CCU-side backups (REST)¶
Separately from daemon backups, OpenCCU-Loom can trigger and store a backup of the CCU (the Homematic .sbk archive) through the REST API. Only an OpenCCU and an openccu-lite system can do this: the backup the daemon triggers on a CCU is the one OpenCCU ships, so for a stock CCU and debmatic the daemon reports system.backup.create and system.backup.restore as not supported and the Config UI does not offer the backup view. These routes are admin-gated:
| Method | Path | Purpose |
|---|---|---|
POST | /api/v1/backups | trigger a CCU backup (returns a job id) |
GET | /api/v1/backups | list locally stored CCU backups (daemon-global, across all configured centrals) |
GET | /api/v1/backups/{id}/download | stream a stored .sbk |
POST | /api/v1/backups/{id}/restore | restore a stored CCU backup by its id |
POST /api/v1/backups accepts an optional JSON body {"central_name": "..."} to target a specific central explicitly; an omitted or empty body backs up the first registered central (the multi-CCU-correct path is naming the central).
A listed entry carries filename, the archive's name in the CCU's own convention (<hostname>-<CCU firmware version>-<YYYY-MM-DD-HHMM>.sbk), recorded when the archive was taken; the download is served under it. Show or store that name rather than deriving one from id, which is a storage key and carries no firmware version. It is absent for archives taken before the field existed — fall back to <id>.sbk.
An openccu-lite system with backup encryption switched on serves an encrypted archive (.sbk.age). The daemon stores it as served but cannot look inside it, so the structural check a restore and an import run is skipped for it, and it is only ever sent to an openccu-lite system — see below.
Backing up and restoring an openccu-lite system¶
The same four endpoints work against an openccu-lite central, backed by the box's own backup API instead of the CCU's CGI:
POST /api/v1/backupsdownloads a fresh backup archive the box creates on request; the archive keeps the box's own file name and is served encrypted (.sbk.age) when the box owner has turned on backup encryption there.POST /api/v1/backups/{id}/restoreuploads the archive to the box, which checks it before applying anything. An encrypted.sbk.agearchive is passed to the box unopened — the daemon never holds its key and cannot inspect it — and the box opens an archive encrypted to its own key itself. The daemon recognises such an archive by its content, not its name, and sends it only to an openccu-lite central; restoring it on a CCU is refused before anything is sent. An archive that needs the box's recovery key to decrypt is refused by the box; restore that one directly on the box instead. An archive the box otherwise rejects (wrong hardware, corrupt) is refused the same way.- An encrypted archive can also be imported (
POST /api/v1/backups/upload, or Import… in the Config UI) while an openccu-lite central is configured; it is stored unopened, listed with an Encrypted badge, and the import answers without a firmware version. Without an openccu-lite central the import is refused, because no configured system could restore it. A restore the box accepts reboots it; the central goes through its normal "waiting for the system" readiness phase and comes back once the box is up again. - Both operations need the token scope named in Connecting an openccu-lite system (
backupto create,powerto restore) and are refused, naming the scope, without contacting the box when it is missing. The Config UI shows the Restore button disabled, with that reason, for a central whose token lacks the scope; it hides it only where the system offers no restore at all.
Where the archives are stored¶
By default in <data_dir>/backups. Set backup.dir to move them elsewhere, for instance onto a mount that is not the daemon's state directory.
The default is the wrong one in a single case that matters: installed as CCU add-on software the daemon's data directory sits under /usr/local/addons/, which is exactly the tree the CCU packs into its own .sbk. Every CCU backup would then carry all previously downloaded archives, and the next one would carry those again. Two things prevent it:
- the daemon writes a
.nobackupmarker into whichever directory it uses, which the CCU's owntarhonours (--exclude-tag), and - the CCU add-on's service script resolves
backup.dirto the CCU's own backup target at every start —/usr/local/etc/config/CronBackupPathwhen the operator has set one, otherwise external storage when the system reports any — so the archives land beside the CCU's own rather than on its internal flash.
See the REST + WebSocket API reference (docs/integrations/rest-ws.md) for request/response detail.
See also¶
- Configuration reference —
data_dirand secrets. - Security guide — at-rest encryption details.
- Troubleshooting — recovering from a lost
secret.key.