An offline copy is only worth something if production cannot reach it. When the Proxmox Backup Server datastore sits on ZFS, the most direct way to make one is to replicate the dataset itself, with zfs send and zfs receive over SSH, to a disk that is then unplugged.
This is part of the method we use to take our customers' datastores offline, notably for AirGapped Drive PBS. Here it is, commands included.
Two ways to get a PBS datastore out
PBS offers its own route: a removable datastore fed by a sync job. ZFS replication is another. They do not copy the same thing.
| Removable datastore + sync job | zfs send | zfs receive | |
|---|---|---|
| What is copied | a second datastore, filled snapshot by snapshot | the datastore itself: chunks, owners, namespaces, verification state |
| Where the media is plugged in | into the PBS that declares the removable datastore | into any ZFS machine; a single one can feed several disks |
| Requirements | no specific file system | ZFS on both sides, one dataset per datastore |
| History | transfer-last: only the last N snapshots per group | the whole content of the dataset, unfiltered |
| Updates | index comparison, then the missing chunks are sent | the blocks changed since the last common point |
| Layout on the media | new files, written according to the media's settings | the original layout: a different recordsize on the disk re-splits nothing |
The sync job is no second-best: it does not require ZFS, and transfer-last divides the volume when the offline copy does not need the whole history. We chose ZFS because our datastores already live on it, and the copy is then the datastore itself: nothing to rebuild on the day it has to be read.
What about rsync of the .chunks directory? Possible on a stopped datastore. While PBS is writing or the garbage collector is running, walking millions of files has no consistency point. A ZFS snapshot is atomic: that is what makes a live copy possible.
The principle: the copy pulls
The machine holding the disk fetches the data, over SSH. Production never pushes. This is not an implementation detail, it is what holds everything else together:
- production holds no credentials for the copy, and has no route to it;
- if production is compromised, an attacker who gets root there can see that a copy comes to collect data — an authorised key, connections in the logs — but has neither credentials nor a route to open a connection to it;
- it is exactly the model of media you unplug — it comes to help itself, then disappears.
On the firewall, a single opening: from the copy to production, over SSH. Snapshots, and their deletion, remain decided by production itself.
1. On production: an SSH key for the copy
The copy connects to production with an SSH key authorised on production. That is all the setup needs, and production, for its part, has no key to the copy. Restrict that key as far as your operations allow (restrict, from=, forced command): without restrictions, whoever takes over the copy machine takes over production too.
How we manage our own access is part of our operations: it is not detailed here.
Who takes the snapshots? Production, with its own timer: it keeps control of its own snapshots. The same timer places a bookmark on each snapshot: it costs nothing, and it is what later sends will use as their origin.
S=offline-$(date -u +%Y%m%dT%H%M)
zfs snapshot rpool/datastores/clientX@$S
zfs bookmark rpool/datastores/clientX@$S rpool/datastores/clientX#$SShort-lived snapshots, long-lived bookmarks
A snapshot kept for weeks holds on to every block the PBS garbage collector frees: the production pool silently grows. Snapshots are therefore destroyed after a few days; bookmarks, which hold no blocks, are kept as long as a disk in the rotation depends on them. A full send, on the other hand, always starts from a snapshot that still exists.
2. On the copy: preparing the disk
ls -l /dev/disk/by-id/ | grep -v part # the disk, by its stable identifier
zpool create -o ashift=12 -o compatibility=openzfs-2.2 -O atime=on airgap-A1 /dev/disk/by-id/<disk>
zfs create -o mountpoint=/mnt/datastore airgap-A1/copies/dev/disk/by-id, never/dev/sdX, which changes from one plug-in to the next;compatibility=openzfs-2.2freezes the pool's features at those of OpenZFS 2.2 (or the level of your choice, see/usr/share/zfs/compatibility.d/). A pool created on a newer OpenZFS can enable features an older OpenZFS does not know, andzpool importthen refuses the disk: freezing them in advance guarantees it will also import on a less up-to-date server;ashift=12set explicitly: a USB bridge or a LUN sometimes reports 512-byte sectors on a disk that works in 4 KiB;atime=on: the PBS garbage collector relies on chunk atime, and PBS checks it when the datastore is declared;- the mountpoint on the parent: received datasets land directly under
/mnt/datastore/<name>.
A disk that leaves the room can get lost: encrypt the media, and never store the key with the disk. Client-side encryption does not make this unnecessary — it protects the content of the backups, not the media, and it remains optional in PBS.
At Nimbus, the disks we put in the safe are encrypted, whether or not you encrypt your backups on your side. If you do — which we recommend — the two protections stack without depending on each other: ours protects the media, yours protects the content. How we encrypt and keep the keys is part of our operations; it is not published here, and the commands in this article are given unencrypted.
3. The first copy
set -o pipefail
SRC=rpool/datastores/clientX
PROD="-i /root/.ssh/id_ed25519_copy pbs-production"
ssh $PROD zfs list -t snapshot,bookmark -o name,creation -r $SRC # what is available
ssh $PROD zfs send -nP -L -c $SRC@offline-20260914T0700 # estimated size, nothing is transferred
ssh $PROD zfs send -L -c $SRC@offline-20260914T0700 \
| pv -L 20m \
| zfs receive -s -u -x mountpoint airgap-A1/copies/clientX
echo "PIPESTATUS=${PIPESTATUS[*]}" # three 0s, otherwise nothing is settledsend -L -csends blocks as they are compressed on disk, without decompressing them or re-splitting those larger than 128 KiB;receive -smakes the receive resumable: if the link drops, you pick up where you left off instead of starting over;-u: the received dataset is not mounted;-x mountpoint: the received dataset takes the mountpoint of its local parent, even if the stream carries properties (a send with-por-R);pv -Lcaps the throughput, so the link is not eaten up during backups;PIPESTATUS: three commands, three exit codes. Looking only at the last one is not enough.
TOKEN=$(zfs get -H -o value receive_resume_token airgap-A1/copies/clientX)
ssh $PROD zfs send -t $TOKEN | pv -L 20m | zfs receive -s -u airgap-A1/copies/clientXDo not judge by the first few minutes: the send starts by walking the metadata of millions of chunks, with a cold cache, and starts slowly before settling into its pace.
4. Checking the copy before it goes to the safe
Never declare the received dataset itself in PBS for a routine check: a datastore in service writes (locks, atime), the dataset diverges, and the next incremental is rejected. Read a throwaway clone instead — which means PBS has to be installed on the machine that carries the disks; ZFS alone is not enough:
zfs clone -o readonly=off -o mountpoint=/mnt/controle \
airgap-A1/copies/clientX@offline-20260914T0700 airgap-A1/controle
proxmox-backup-manager datastore create controle /mnt/controle --reuse-datastore true
proxmox-backup-manager verify controle --ignore-verified false
proxmox-backup-manager datastore remove controle # removes the declaration, not the data
zfs destroy airgap-A1/controle
zpool status airgap-A1 # 0 errorsThe verify that verifies nothing
The verification state travels with the replication: the copy arrives with the source's verify history. A regular verify then answers SKIPPED: … (recently verified) without reading a single byte, and you believe you have checked a disk you never re-read. --ignore-verified false forces the chunks to actually be re-read. Day to day, this inheritance is handy; for testing media, it is misleading.
5. When a disk comes back: the incremental
Each disk comes back with a different lag, so it restarts from its own last send. The last snapshot on the disk has the same name as a bookmark on production: that is the origin of the incremental.
zpool import airgap-A1
zfs list -t snapshot -o name airgap-A1/copies/clientX # last snapshot received by THIS disk
ssh $PROD zfs send -nP -L -c -i $SRC#offline-20260829T0700 $SRC@offline-20260914T0700 # size of the delta
ssh $PROD zfs send -L -c -i $SRC#offline-20260829T0700 $SRC@offline-20260914T0700 \
| pv -L 20m | zfs receive -s -u airgap-A1/copies/clientX
echo "PIPESTATUS=${PIPESTATUS[*]}"The options that matter for an incremental
| Option | What it does | When to use it |
|---|---|---|
send -i <origin> <snap> | sends only the difference between the origin and the snapshot. The origin can be a bookmark. | the normal case: origin = bookmark of the last send to this disk |
send -I <origin> <snap> | also sends every intermediate snapshot. The origin must be a snapshot, not a bookmark. | rarely useful here: the datastore already carries its own history |
send -c | carries compressed blocks as they are | always, for the full send as for incrementals |
send -L | keeps blocks larger than 128 KiB. Without it, -c decompresses and re-splits them. | always, with -c: no effect when blocks are 128 KiB or smaller, essential if the datastore's recordsize is or has ever been above 128 KiB |
send -nP | computes the stream size without sending anything | before each rotation, to know what it will cost |
send -t <token> | resumes an interrupted send where the receive left it | after a cut, with the target's receive_resume_token |
receive -s | keeps the partial state if interrupted | on every send, incrementals included |
receive -u | does not mount the dataset | on every send: a mounted dataset ends up being written to |
receive -A <dataset> | discards a resumable partial state | when resuming is no longer possible and the incremental has to be restarted |
receive -F | rolls back writes made on the target since its last snapshot, and can destroy snapshots on the target | never by reflex: only when you know what you are overwriting |
- if
zfs receiverefuses because the destination has been modified, someone wrote to the received dataset — most often by mounting it; - if the bookmark has disappeared on production, there is no common point left: start over with a full copy;
-x mountpointonly matters on the first send, which creates the dataset;- on the disk, only the last received snapshot is needed for the next incremental. Earlier ones hold blocks that PBS has freed since.
6. Unplugging
zpool export airgap-A1 # always before unplugging, never afterIf the disk is on USB, four checks, once per enclosure model:
- UAS or BOT (
lsusb -t, look forDriver=uas). Inusb-storagemode, one command at a time: throughput hits a ceiling. If the kernel itself disabled UAS for that bridge, it has its reasons — change enclosures rather than forcing it; - SMART through the bridge:
smartctl -a -d sat. If no-dworks, this enclosure does not let you monitor the disk; - the serial number seen in
/dev/disk/by-idis sometimes the bridge's, identical for every disk. Rely on the pool name, which ZFS reads from the disk itself; - a powered enclosure for a 3.5-inch drive. A disconnection mid-write suspends the pool:
zpool clear, then resume with the token.
What this copy gives you, and what it does not
- Unplugged, the disk is out of reach: ransomware can neither encrypt nor erase media connected to nothing;
- a compromised production does not lead to the copy: the copy pulls, and production has neither credentials nor a route to it;
- a lost or stolen disk gives nothing away without its encryption key, which never travels with it;
- with client-side encryption enabled, the content stays unreadable to anyone without your key — including us;
- what has been backed up since the last send is not on the disk: the offline copy is as old as its last update;
- a deletion on production gets copied: if the backups are wiped there, the next incremental carries the deletion onto the refreshed disk. That is by design — the copy is the datastore itself. What protects you then are the other media, still in the safe with the state from before;
- a copy never read back is not a backup: the forced verify, then an actual restore from time to time, are the only proof.
What next?
The day you need it, the disk comes out of the safe and its datastore has to start again somewhere else. Here is how we reactivate a single datastore on a new PBS: import, declaration, access, Proxmox VE, and the proof that you can restore.
Reactivate a PBS datastore after losing the serverFrequently asked questions
Why zfs send rather than a PBS sync job for the offline copy?
A sync job to a removable datastore is the official route, and it has real strengths: it does not require ZFS, and the transfer-last option limits the copy to the most recent snapshots of each group. zfs send copies the datastore itself, with its owners, namespaces and verification state, and a single machine can feed several disks. Our datastores already live on ZFS: production then only has to hand out a stream.
Can a compromised production PBS reach the offline copy?
Not with this setup. The copy connects to production over SSH, never the other way round: production holds no credentials for the copy and has no route to it. An attacker who gets root on production can see that a copy comes to collect data, but has no way to open a connection to it. And once the disk is unplugged, it is connected to nothing.
Why a bookmark rather than a snapshot kept on production?
A snapshot kept for weeks holds on to every block the PBS garbage collector frees in the meantime: the production pool grows. A bookmark holds no blocks, and it is enough as the origin of an incremental send with zfs send -i. Each disk in rotation needs the bookmark of its last send.
Why does zfs receive reject the incremental with "destination has been modified"?
Because the received dataset has been written to since its last snapshot — typically because it was mounted and declared in PBS, which writes locks and updates atime. That is why the copy is checked on a throwaway clone. zfs receive -F would roll those writes back, but it can also destroy snapshots on the target: only use it when you know what you are overwriting.
Should I run a verify again when the disk comes back from the safe?
Yes if you want proof that it is readable, and you then have to force it. The verification state travels with the replication: the copy arrives with the source's verify history, and a regular verify answers "recently verified" without reading anything. proxmox-backup-manager verify <datastore> --ignore-verified false actually re-reads the chunks.
Offline, without the disks to manage
AirGapped Drive PBS: an online copy and an offline rotation in the safe, at €34 excl. VAT/TB/month. The disks, their checks and their rotation are our business.
Want to be alone on your media, and be able to borrow it — dedicated disks or an offline PBS that boots, subject to a deposit? Dedicated media is priced on quote.
