dillard 6cbc5d79d4
CI / unit (8.2) (push) Has been cancelled
CI / unit (8.3) (push) Has been cancelled
CI / unit (8.4) (push) Has been cancelled
CI / psalm (push) Has been cancelled
CI / integration (mariadb, 32) (push) Has been cancelled
CI / integration (mariadb, 33) (push) Has been cancelled
CI / integration (mariadb, 34) (push) Has been cancelled
CI / integration (mariadb, 35) (push) Has been cancelled
CI / integration (pgsql, 32) (push) Has been cancelled
CI / integration (pgsql, 33) (push) Has been cancelled
CI / integration (pgsql, 34) (push) Has been cancelled
CI / integration (pgsql, 35) (push) Has been cancelled
CI / integration (sqlite, 32) (push) Has been cancelled
CI / integration (sqlite, 33) (push) Has been cancelled
CI / integration (sqlite, 34) (push) Has been cancelled
CI / integration (sqlite, 35) (push) Has been cancelled
CI / integration-targets (push) Has been cancelled
CI / integration-user (push) Has been cancelled
Phase 2: per-user backup and restore via user_migration
Wires the existing RepositoryExportDestination/RepositoryImportSource (see
lib/UserMigration/) into user_migration's own UserMigrationService, resolved on demand
through a new UserMigrationServiceLocator since the app is an optional dependency
(PLAN.md 3.8) -- without it, only this feature is unavailable, the full backup still
works.

New: UserBackupService::backupUser() (occ backup:backup:user) streams a user_migration
export straight into the repository, under the same shared location lock as a regular
backup. UserRestoreService::restoreUser() (occ backup:restore:user) never imports over a
live account: --mode=replace safety-exports the current state first, then deletes and
recreates the account from the backup under the same uid; --mode=as-backup restores into
a new "<uid>-bak" account instead, since Nextcloud cannot rename a uid.

The catalog now also tracks user export manifests (Repository::recordUserExport/
listUserExports/forgetUserExport/isRecordedUserExport), rollback-protected like a
snapshot: listUserExports() verifies each one is still present on the location, and
restore only ever reads a manifest the verified catalog actually recorded, never an
arbitrary caller-supplied path. prune()'s existing raw users/ scan for blob retention is
unchanged. A new short exclusive "catalog" lock (PruneService::catalogLockKey())
serializes the catalog write itself, so two concurrent per-user backups (or one
alongside a full backup) can no longer race to write the same next generation and
silently drop one of them -- the broader per-target shared lock only ever protected
against a concurrent cleanup, not against other shared holders.

Two real bugs found by testing this against a live Nextcloud instance, not by unit
tests (RepositoryExportDestination/RepositoryImportSource implement OCP interfaces that
plain PHPUnit can't load, see tests/Unit/Repository/UserExportTest.php's docblock; the
new tests/integration/user-roundtrip.sh covers the real round trip instead):
- RepositoryImportSource::copyToFolder() double-closed the stream it handed to
  putContent()/newFile() (both already close it themselves), which throws a TypeError on
  PHP 8 and broke every restored file with content.
- A replace-mode restore failed on NotPermittedException: IRootFolder::getUserFolder()
  caches a Folder per uid for the process lifetime, so after delete+recreate of the same
  uid the import still got the deleted account's (now storage-less) folder. Fixed by
  dispatching FilesystemTornDownEvent, which Root already listens for to clear exactly
  that cache -- no internal OC\ classes needed.

Local review (CodeRabbit + Codex) against this diff found three more real issues, all
fixed: the catalog write race above, a failed as-backup import leaving a broken stub
account behind (now deleted on failure), and an export stream leak on a read/backend
failure in RepositoryExportDestination::copyFolder() (now closed in a finally).

Deliberately left out: wiring per-user export into the automatic backup schedule
(on-demand via occ only, for now) and retention/pruning of old user exports -- both open
design questions, not yet decided. The lease covering a single export/import still has a
fixed TTL (3600s) with no mid-call refresh, since UserMigrationService exposes no
progress; a resumable, step-based per-user backup (like the main BackupService run)
would remove that cap but is a bigger change than this pass.
2026-10-05 17:36:46 +02:00
2026-10-02 01:09:47 +02:00
2026-10-02 11:56:57 +02:00
2026-10-02 14:23:25 +02:00
2026-10-05 11:45:48 +02:00
2026-10-05 11:45:48 +02:00

NG Backup

Encrypted, incremental backups of Nextcloud (database, configuration and data) with restore from the web interface. Works without shell access: no external programs, no extra PHP extensions beyond sodium. Runs on Nextcloud All-in-One, regular installations and hosted Nextcloud.

Status: in development (phase 1). Not ready for production use. See PLAN.md for the design.

License: AGPL-3.0-or-later.

How it works

  • Backups go to a location: S3 (and S3-compatible storage), SFTP, WebDAV, FTP, Swift or a local path. NG Backup uses the connection code of Nextcloud's External storage app, but never mounts the location: it is only connected while a backup or restore runs, and it never appears in Files, for anyone.
  • Data is cut into blocks, deduplicated, compressed and encrypted (XChaCha20-Poly1305) before it leaves the server, and streamed straight to the location: no local copy, no double disk space. Unchanged files are not read again; only new data is uploaded.
  • The database is dumped through Nextcloud's own connection, inside one consistent transaction.
  • Restores go through Nextcloud, so overwritten files keep their old content as a version and removed files go to the trash.

Quick start

occ backup:key:init --label="Safe"                 # first passphrase, deletion delay (default 7 days)
occ backup:key:slot:add --label="CTO"              # optional second and third passphrase
occ backup:key:slot:add --label="Head of IT"
occ backup:key:kit --output=/root/ng_backup-kit.json
occ backup:key:confirm
occ backup:target:add offsite amazons3 -a amazons3::accesskey -o bucket=... -o key=... -o secret=...
occ backup:run

Or use Administration settings → NG Backup.

The backup key, passphrases and the recovery kit

All backups of an installation are encrypted with one master key.

  • The server keeps the master key, encrypted with the instance secret, so scheduled backups and restores work without anyone typing a passphrase. Any administrator can restore from the web interface. Consequence: whoever fully controls the server can read the backups; whoever only gets hold of the backup location cannot.

  • For a restore on a new server (disaster recovery) you need the recovery kit and one passphrase. The kit contains the master key, encrypted separately with each passphrase.

  • Up to three passphrases ("slots") can open the key, so one person leaving or dying does not lock the company out. A suggested setup:

    Slot Kept by
    1 in the safe (sealed envelope)
    2 the CTO
    3 the head of IT

    Every holder keeps a copy of the same recovery kit and only knows their own passphrase. Using a single slot is possible, but then that one passphrase is a single point of failure.

  • When a holder leaves, give their slot a new passphrase (occ backup:key:slot:replace), download the new kit and confirm it. Note: an old kit together with the old passphrase still opens the same master key; to revoke it completely, start new locations with a new key.

  • Downloading the kit, and every change to the slots, requires the administrator's password, is written to the audit log and is announced to all administrators.

Protection against ransomware and a compromised server

A backup is only useful if an attacker cannot destroy it. An attacker with administrator access to Nextcloud could try to remove old restore points so that only infected data remains. NG Backup makes this hard and visible, but software alone can never guarantee it: the location must refuse deletions itself.

What NG Backup does:

  • Deletion delay. Removed restore points first go to a trash on the location and are only deleted after the delay chosen at installation (default 7 days, cannot be changed afterwards). Within that time any administrator can bring them back (occ backup:trash:restore, or in the web interface).
  • Notifications to all administrators when restore points are removed, the retention is changed, append-only is turned off, a location is removed or the recovery kit is downloaded, and an entry in the audit log (admin_audit).
  • Append-only locations (occ backup:target:append-only NAME on): NG Backup never deletes anything there; old data is cleaned up by the location's own lifecycle rules.
  • Removing a location in NG Backup only removes the connection. The backups on the location are not touched and nothing keeps cleaning up in the background.

What you should do on the location (strongly recommended):

  • S3: enable Object Lock on the bucket, in compliance mode, with a default retention of for example 90 days, plus versioning. Then nobody, not even with the server's credentials, can delete or overwrite backups younger than 90 days. Combine with an append-only location in NG Backup and a lifecycle rule that removes old versions after the lock expires. With Object Lock, a deletion by NG Backup (or by an attacker through it) only adds a delete marker: the locked data stays and can be brought back from the bucket's object versions until the lock expires. Tested with an S3-compatible server; still to be verified on AWS itself, which requires a checksum on uploads to locked buckets.
  • Use credentials without delete rights for the backup location where the storage supports it.
  • SFTP / storage boxes: enable the provider's own snapshots (e.g. daily, kept 30-90 days) and give the backup user access to its own folder only, without a shell.
  • Keep at least one location off-site, on a different network than the Nextcloud server.

Restore

  • Files and folders of a user: in the web interface or occ backup:restore, into a new folder ("Restored "), merged into the original place, or replacing it.
  • Raw restore of any path (for example config/) to a local directory: occ backup:restore ... --to-directory=DIR.
  • Whole server (disaster recovery onto a fresh installation): planned for phase 2.

Storage backend experiences

Notes from actually running a backend, beyond what's in files_external's own documentation. Contributions welcome via PR — add what you ran into with your own provider.

  • Hetzner Storage Box, sub-account: the SFTP backend's root option must be home, not /. A sub-account's SFTP login lands in /home (confirmed via pwd() after login); writing to the literal chroot root (/) fails with a generic NET_SFTP_STATUS_FAILURE from the SFTP server, while the same write under home (or a path below it) succeeds. This affects any backend option form that lets you set a path relative to the account's root, not just ng_backup. Not yet verified whether this also applies to a Hetzner Storage Box main account (only tested with a sub-account so far) — if you've tried a main account, please say so in a PR.

Commands

Command Purpose
backup:key:init, backup:key:kit, backup:key:confirm create the key, recovery kit, confirmation
backup:key:slots, backup:key:slot:add/replace/remove passphrase slots
backup:target:backends/add/list/remove/append-only backup locations
backup:run, backup:status, backup:list back up, status, restore points
backup:browse, backup:restore browse and restore
backup:retention, backup:prune, backup:trash, backup:trash:restore retention, cleanup, trash
S
Description
No description provided
Readme AGPL-3.0 8.2 MiB
1.1.0 Latest
2026-11-09 17:44:26 +01:00
Languages
PHP 84.9%
Shell 9.7%
Vue 5%
JavaScript 0.4%