The Boring Offsite Backup Box
19 May 2026
The useful thing about a backup target is not that it can run a lot of services. The useful thing is that it can receive the data you care about, keep it away from the machine that normally owns it, and give you a restore path when the main box fails.
A Raspberry Pi is fine for this if the job stays small. I mainly built this one because I already had the Pi hardware lying around and wanted a ZFS receive target. No grand procurement strategy. Use what exists, give it one job, cut the rest.
Mine is a Raspberry Pi 5 with a separate NVMe SSD, running Ubuntu Server, reachable over Tailscale, and used as a narrow ZFS receive target. It is not a NAS. It is not a media server. It is not DNS, monitoring, ingress, dashboard, or “while I am here” infrastructure. It receives selected snapshots. Nothing else gets a vote.
If you want the shortest mental model for this guide, it is this: build the target, create one receive pool, seed one dataset locally, send one incremental remotely, then prove you can restore a file from a received snapshot.
The box has been built, seeded locally, moved offsite, and tested from the remote location. I still keep cloud backup as a separate offsite layer. A Pi with one NVMe is useful. It is not a complete backup strategy.
What The Box Is Allowed To Do
The target has one job: receive selected ZFS snapshots from the primary NAS.
It is allowed to run:
- SSH
- ZFS tooling
- disk health tooling
- Tailscale for private access
- enough logging or alerting to notice failures
It is not allowed to run:
- SMB or NFS shares
- DNS
- containers
- dashboards
- public reverse proxies
- subnet routing
- spare services because the hardware has spare capacity
This is partly a security boundary and partly a maintenance boundary. The moment a backup target becomes a second homelab, it collects patches, secrets, open ports, and broken little services. That is the opposite of disaster recovery.
Hardware I Used, Not A Shopping List
This is the hardware I used for the current build:
| Role | Part |
|---|---|
| Board | Raspberry Pi 5 16GB |
| Case / HAT | GeeekPi P33 M.2 NVMe M-Key PoE+ HAT and aluminium case |
| Data disk | Crucial P310 2TB M.2 2280 NVMe SSD |
| Boot media | SanDisk 32GB Ultra microSDHC card |
| Operating system | Ubuntu Server 24.04 LTS |
| Boot path | SD boot |
| Power path in current build notes | PoE+ through the HAT |
| Private access | Tailscale |
That table is a build note, not a recommendation. Raspberry Pi 5 USB-C power and PoE+ through a HAT are separate power paths. If you copy this shape, confirm which one you are actually using and do not feed both at once.
The SD card is a compromise, not a virtue. I would not use microSD as OS media for a busy server with containers, databases, and noisy logging. Here the risk is bounded: the OS is disposable, the data lives on the NVMe ZFS pool, and the box is not meant to write much. If the card dies, the recovery path is to rebuild the OS and import the pool.
If the NVMe data disk dies, the recovery path is different: replace the disk, recreate the receive pool, reseed from the primary NAS or another backup copy, and repeat the restore check. A dead SD card is an OS rebuild. A dead replica disk is backup-copy replacement. The box stays manageable because those are different failures with different remedies.
That simplicity is part of the recovery plan. One job, a short package list, and a documented pool import path make the box rebuildable.
The Raspberry Pi 5 product page describes the board’s PCIe 2.0 x1 interface, USB 3.0 ports, Gigabit Ethernet, and 5V/5A USB-C power guidance, with PoE+ available through a separate HAT. The official M.2 HAT+ documentation repeats the warning around PCIe Gen 3. For this box, I kept the storage path at the documented Gen 2 behaviour because a backup target should not depend on a clever storage link.
The P33 HAT is third-party hardware. The 52Pi/GeeekPi product material describes it as an M.2 NVMe and PoE+ board for Raspberry Pi 5, but I used it because it fit the job, not because this article is trying to found a tiny church around it. The Crucial P310 is the same story: it is the drive I used.
The validation I recorded was deliberately modest:
- the NVMe device appeared
- the kernel log showed PCIe Gen 2 and the NVMe block device
vcgencmd get_throttledreturnedthrottled=0x0- the filtered kernel log check did not report undervoltage or throttling warnings
That is enough to say the box came up cleanly for this role. It is not enough to publish performance numbers, thermal margins, or a hardware recommendation with a straight face.
The Placeholder Table
The placeholders below are only a decoding aid. Replace them once, then continue with the real sequence.
This guide has two stages:
- the manual path: build the target, send a seed, send an incremental, prove a restore
- the optional automated path: add a small sender script and a
systemdtimer after the manual path works
| Placeholder | Replace with |
|---|---|
primary-nas | source NAS hostname reachable by SSH or Tailscale |
backup-target | Raspberry Pi receive target hostname |
replica | ZFS pool name on the receive target |
replica/primary-nas | receive root dataset on the target |
zfs-receive | dedicated replication user on the target |
~/.ssh/primary-nas-to-backup-target | dedicated private key on the source NAS |
/root/.ssh/primary-nas-to-backup-target | dedicated private key used by the scheduled root service |
/dev/disk/by-id/nvme-EXAMPLE_BACKUP_SSD | stable disk-by-id path for the target SSD |
primary/app-state | selected source dataset |
seed-2026-05 | first seed snapshot name |
daily-2026-05-20 | later incremental snapshot name |
primary-nas-to-backup-target | key comment for the dedicated SSH key |
The destructive command is zpool create. If the disk path is wrong, you will destroy the wrong disk. Confirm the disk path before doing the expensive bit.
Bring The Pi Up Boringly
On this build I used Ubuntu Server for Raspberry Pi, SD boot, and a separate NVMe SSD for the replica pool. In my current build notes the Pi is powered over PoE+, but treat that as a recorded build detail rather than a requirement. The exact distribution is less important than the separation: the boot media is disposable, while the receive pool is the data copy I care about.
Use Raspberry Pi Imager to write the boot media. In the Imager flow, choose the Raspberry Pi model, choose the Ubuntu Server image you intend to run, choose the microSD card, and apply only the settings you are comfortable treating as boot-media configuration. Hostname, user, SSH, and network setup are convenient there, but do not turn screenshots of that step into public documentation. They tend to contain exactly the kind of identifiers that look harmless until they are not.
On the target, start with the dull checks:
cat /etc/os-release
uname -a
lsblk -o NAME,MODEL,SIZE,TYPE,MOUNTPOINTS
ls -l /dev/disk/by-id/
vcgencmd get_throttled
journalctl -k -b | grep -Ei 'under.?voltage|thrott|voltage|pcie|nvme|reset|error'
Do not paste raw output from those commands into public posts, screenshots, issues, or comments until you have reviewed and redacted it. Logs can include hostnames, device details, MAC addresses, serial-like identifiers, and other scraps that are more revealing than they look.
For my validation note, the useful result was simple: the NVMe appeared, PCIe was running at the expected Gen 2 baseline, and the throttling state was clean for that boot.
Install the basic tools for the role. On Ubuntu or Debian-family systems that shape looks like this:
sudo apt update
sudo apt install openssh-server zfsutils-linux smartmontools nvme-cli
If you use a different OS image, check the package names and ZFS support for that image. The commands in this article are a model, not a promise that every Raspberry Pi distribution packages ZFS in the same way.
Add Tailscale Access
This guide uses ordinary SSH over Tailscale. It does not require Tailscale SSH, subnet routing, an exit node, or any public inbound service. The box only needs to be reachable by the source NAS for replication and by the admin machine for maintenance.
On Ubuntu or Debian-family systems, the official Tailscale Linux install docs show this install path for mainstream distributions:
curl -fsSL https://tailscale.com/install.sh | sh
sudo tailscale up
If you do not like piping an install script into a shell, use Tailscale’s distribution-specific package instructions instead. Either way, authenticate the Pi into the tailnet before it leaves the building.
Check the target has joined:
tailscale ip
tailscale status
If the source NAS is not already on the same tailnet, install Tailscale there as well. The placeholder backup-target in the later SSH examples should resolve to the Pi’s Tailscale-reachable name or address.
For an offsite box that should stay connected, decide whether to disable key expiry for that machine in the Tailscale admin console. That is an administrative choice, not a magic reliability setting, so record it in the recovery notes.
Create The Receive Pool
On backup-target, identify the stable disk path:
ls -l /dev/disk/by-id/
Set the disk variable only after you have checked the model and path:
# Replace this with the stable /dev/disk/by-id path for your target SSD.
export REPLICA_DISK=/dev/disk/by-id/nvme-EXAMPLE_BACKUP_SSD
This destroys the named disk:
# Replace replica if you chose a different pool name.
sudo zpool create \
-o ashift=12 \
-O compression=lz4 \
-O atime=off \
-O mountpoint=/replica \
replica "$REPLICA_DISK"
Create a receive root for this NAS:
# Replace replica/primary-nas if you chose different pool or receive-root names.
sudo zfs create replica/primary-nas
I like the receive root to be read-only for ordinary filesystem writes:
# Replace replica/primary-nas if you chose different pool or receive-root names.
sudo zfs set readonly=on replica/primary-nas
That does not remove the need to review ZFS receive permissions, SSH access, and the replication path. It just keeps the target pointed at its job.
Replicate The Valuable Set
The article about my broader 3-2-1 backup scope explains why I do not back up the whole NAS offsite. The same idea applies here. The Pi receives selected datasets, not everything that happens to exist.
In the public model, the replicated classes are:
app-statepersonal-filescurated-mediaserver-recovery
The excluded classes are:
tvmoviesendpoint-imagesscratchtranscodecache
Your list will be different. The point is not my taxonomy; it is the discipline of having one. A backup target with an explicit allowlist is easier to understand than a target that receives every spare byte and becomes a restore problem of its own.
Use A Dedicated Receive User
Create a user on backup-target whose job is receiving ZFS streams:
# Replace zfs-receive if you chose a different receive user.
sudo adduser \
--system \
--group \
--home /var/lib/zfs-receive \
--shell /bin/sh \
zfs-receive
The shell matters. OpenSSH runs remote commands through the target user’s shell, so the later ssh zfs-receive@backup-target /usr/sbin/zfs receive ... examples need something executable such as /bin/sh. If you create the user with /usr/sbin/nologin or /bin/false, the account is quieter, but these simple receive commands will not run without a forced-command wrapper.
Check what you created:
# Replace zfs-receive if you chose a different receive user.
getent passwd zfs-receive
Create its SSH directory:
# Replace zfs-receive if you chose a different receive user.
sudo install -d -o zfs-receive -g zfs-receive -m 700 /var/lib/zfs-receive/.ssh
sudo install -o zfs-receive -g zfs-receive -m 600 /dev/null /var/lib/zfs-receive/.ssh/authorized_keys
Delegate the smallest ZFS permission set needed for this example:
# Replace zfs-receive and replica/primary-nas if you changed those names.
sudo zfs allow -u zfs-receive create,mount,receive replica/primary-nas
mount is included here because current OpenZFS delegated-permission rules require it for the receive path. I am still deliberately not adding destroy or rollback to the public command path. Those widen the blast radius more than this first article needs.
On primary-nas, create a dedicated SSH key for this path:
# Replace the key path and comment if you chose different names.
ssh-keygen \
-t ed25519 \
-f ~/.ssh/primary-nas-to-backup-target \
-C primary-nas-to-backup-target
If you only plan to run manual sends, protecting that key with a passphrase is reasonable. If you intend to use the systemd timer later, create a separate dedicated service key without a passphrase or use another non-interactive mechanism you trust after reboot. The timer example below assumes the replication key does not stop to ask for a passphrase.
To keep the command examples short, the rest of this guide uses one placeholder key name. If you split manual and automated access into two different keys, repeat the same authorized_keys pattern for the service key as well.
Show the public key:
# Replace this path if you chose a different key path.
cat ~/.ssh/primary-nas-to-backup-target.pub
On backup-target, add that one public key line to:
/var/lib/zfs-receive/.ssh/authorized_keys
For the simple version of this guide, prefix the key with restrict:
# Replace AAAA...EXAMPLE with the public key from primary-nas.
restrict ssh-ed25519 AAAA...EXAMPLE primary-nas-to-backup-target
That is not as tight as a forced-command wrapper, but it removes common SSH extras such as forwarding and PTY allocation while still allowing the non-interactive receive commands used below. Do not reuse your normal interactive admin key because it happens to be convenient.
After editing authorized_keys, re-check ownership and permissions:
# Replace zfs-receive if you chose a different receive user.
sudo chown zfs-receive:zfs-receive /var/lib/zfs-receive/.ssh/authorized_keys
sudo chmod 600 /var/lib/zfs-receive/.ssh/authorized_keys
Before sending any ZFS stream, test the private path from primary-nas:
# Replace the key path, receive user, target host, pool, and receive root.
ssh \
-i ~/.ssh/primary-nas-to-backup-target \
-o IdentitiesOnly=yes \
zfs-receive@backup-target \
'/usr/sbin/zfs list -H -o name replica/primary-nas'
That command should connect over the Tailscale-reachable name or address and print the receive root. If it does not, fix SSH, Tailscale, the user’s shell, or the ZFS receive root before thinking about snapshots.
Seed It Locally
The first send is the ugly one. If possible, do it locally before moving the box. Sending the initial dataset set across local Ethernet beats discovering a remote broadband bottleneck after the Pi has already been placed somewhere inconvenient.
On primary-nas, create a first snapshot:
# Replace primary/app-state and seed-2026-05 with your dataset and seed snapshot name.
sudo zfs snapshot primary/app-state@seed-2026-05
Send it to backup-target:
# Replace the dataset, snapshot, key path, receive user, target host, and target dataset.
sudo zfs send primary/app-state@seed-2026-05 \
| ssh \
-i ~/.ssh/primary-nas-to-backup-target \
-o IdentitiesOnly=yes \
zfs-receive@backup-target \
/usr/sbin/zfs receive -u replica/primary-nas/app-state
Repeat that pattern for each selected dataset class. Do not send datasets merely because they are nearby. Proximity is not a backup policy.
Send Incrementals After The Seed
After a later snapshot:
# Replace primary/app-state and daily-2026-05-20 with your dataset and snapshot name.
sudo zfs snapshot primary/app-state@daily-2026-05-20
Send the incremental stream:
# Replace both snapshot names, the key path, receive user, target host, and target dataset.
sudo zfs send -i primary/app-state@seed-2026-05 primary/app-state@daily-2026-05-20 \
| ssh \
-i ~/.ssh/primary-nas-to-backup-target \
-o IdentitiesOnly=yes \
zfs-receive@backup-target \
/usr/sbin/zfs receive -u replica/primary-nas/app-state
OpenZFS documents the general send and receive model: a full stream sends a complete snapshot, while an incremental stream sends the difference from an earlier snapshot. The receive side recreates that snapshot history on the target.
Optional: Schedule The Incremental Sends
The timer section is not the backup. It is only a convenience layer added after the manual send and restore path already work.
If manual sends are enough for your current setup, you can stop here. For a first scheduled version, keep it boring: one script, one dataset, one systemd timer, logs in the journal. Clone the pattern for each selected dataset only after the first one works.
The example below runs on primary-nas as a system service. Because ZFS snapshot and send operations usually need root, this starter version runs as root and uses a root-owned SSH key at:
# Replace this path if you choose a different root-owned key path.
/root/.ssh/primary-nas-to-backup-target
That key should be separate from your interactive admin key and should have its public half installed for zfs-receive on backup-target, as shown earlier.
The timer example below assumes this dedicated replication key is non-interactive: no passphrase, no agent dependency, nothing that expects a human to appear at 03:15.
If the dedicated non-interactive service key currently lives in your own SSH directory, copy that service key into root’s SSH directory before enabling the timer:
# Replace both key paths if you chose different names.
sudo install -d -m 700 /root/.ssh
sudo cp ~/.ssh/primary-nas-to-backup-target /root/.ssh/primary-nas-to-backup-target
sudo chmod 600 /root/.ssh/primary-nas-to-backup-target
Do not copy your normal admin key here. The scheduled service should use the same narrow replication key you tested earlier.
Before enabling the timer, run the same path as root once. This is the non-interactive path check. It confirms that root can use the dedicated key, gives you the normal SSH host-key verification prompt for the exact backup-target name the timer will use, and seeds /root/.ssh/known_hosts before the unattended run:
# Replace the key path, receive user, target host, pool, and receive root.
sudo ssh \
-i /root/.ssh/primary-nas-to-backup-target \
-o IdentitiesOnly=yes \
zfs-receive@backup-target \
'/usr/sbin/zfs list -H -o name replica/primary-nas'
If that root-side pre-flight fails, fix host-key trust, key permissions, or target-side access now rather than discovering the problem from a dead timer.
After the manual seed has completed, initialise the state file with the seed snapshot name:
# Replace seed-2026-05 and primary-app-state if you use a different seed or dataset.
sudo install -d -m 700 /var/lib/zfs-offsite
printf '%s\n' seed-2026-05 | sudo tee /var/lib/zfs-offsite/primary-app-state.last >/dev/null
Do not delete primary/app-state@seed-2026-05 from the source until the first scheduled incremental has succeeded. The first scheduled run needs that snapshot as its base.
Create a small sender script on primary-nas:
# Replace app-state in the script filename if you use a different dataset.
sudo tee /usr/local/sbin/zfs-send-app-state-to-backup-target >/dev/null <<'EOF'
#!/usr/bin/env bash
set -euo pipefail
# Replace these values before running the script.
SOURCE_DATASET="primary/app-state"
TARGET_HOST="backup-target"
TARGET_DATASET="replica/primary-nas/app-state"
SSH_KEY="/root/.ssh/primary-nas-to-backup-target"
STATE_DIR="/var/lib/zfs-offsite"
STATE_FILE="${STATE_DIR}/primary-app-state.last"
SNAPSHOT="auto-$(date -u +%Y%m%dT%H%M%SZ)"
if [[ ! -s "$STATE_FILE" ]]; then
echo "Missing state file: $STATE_FILE" >&2
exit 1
fi
PREVIOUS="$(cat "$STATE_FILE")"
zfs snapshot "${SOURCE_DATASET}@${SNAPSHOT}"
zfs send -i "${SOURCE_DATASET}@${PREVIOUS}" "${SOURCE_DATASET}@${SNAPSHOT}" \
| ssh \
-i "$SSH_KEY" \
-o BatchMode=yes \
-o IdentitiesOnly=yes \
"zfs-receive@${TARGET_HOST}" \
"/usr/sbin/zfs receive -u ${TARGET_DATASET}"
printf '%s\n' "$SNAPSHOT" > "$STATE_FILE"
EOF
# Replace app-state in the script filename if you use a different dataset.
sudo chmod 700 /usr/local/sbin/zfs-send-app-state-to-backup-target
Test the script manually before adding a timer:
# Replace the script path if you used a different name.
sudo /usr/local/sbin/zfs-send-app-state-to-backup-target
Then create a systemd service:
# Replace app-state in the service filename if you use a different dataset.
sudo tee /etc/systemd/system/zfs-offsite-app-state.service >/dev/null <<'EOF'
[Unit]
# Replace app-state and backup-target in this description if you changed them.
Description=Send app-state ZFS snapshot to backup-target
Wants=network-online.target
After=network-online.target
[Service]
Type=oneshot
# Replace this path if you used a different sender script name.
ExecStart=/usr/local/sbin/zfs-send-app-state-to-backup-target
EOF
Create the timer:
# Replace app-state in the timer filename if you use a different dataset.
sudo tee /etc/systemd/system/zfs-offsite-app-state.timer >/dev/null <<'EOF'
[Unit]
# Replace app-state and backup-target in this description if you changed them.
Description=Daily app-state ZFS send to backup-target
[Timer]
# Replace this schedule with the time you want the backup to run.
OnCalendar=*-*-* 03:15:00
RandomizedDelaySec=15m
Persistent=true
# Replace this service name if you used a different service filename.
Unit=zfs-offsite-app-state.service
[Install]
WantedBy=timers.target
EOF
Enable it:
# Replace app-state if you used different service or timer names.
sudo systemctl daemon-reload
sudo systemctl enable --now zfs-offsite-app-state.timer
systemctl list-timers --all zfs-offsite-app-state.timer
The systemd.timer documentation covers the timer shape: OnCalendar= schedules wall-clock runs, and Persistent=true means a missed run can fire after the timer becomes active again.
Check failures in the journal:
# Replace app-state if you used a different service name.
journalctl -u zfs-offsite-app-state.service
This is still not a full replication framework. It does not prune old snapshots. It does not alert you when the target disappears. It does not decide which datasets matter. It is one boring scheduled path for one selected dataset, and that is all it claims to be.
Restrict The SSH Path
The receive key above is minimally restricted, but it is still allowed to run non-interactive commands as zfs-receive. That is enough for the public example to work. It is not the hardened end state.
OpenSSH supports stronger restrictions in authorized_keys, including forced commands via command="..." in the server’s sshd behaviour. The tighter shape looks like this:
# Replace the wrapper path, public key, and key comment before using this pattern.
restrict,command="/usr/local/sbin/zfs-receive-wrapper" ssh-ed25519 AAAA...EXAMPLE primary-nas-to-backup-target
For this article, I am showing the shape rather than publishing a wrapper. A wrapper that safely accepts ZFS receive streams is security-sensitive enough to deserve proper treatment rather than a heroic sidebar.
The minimum rule is still simple: use a dedicated key, restrict the basic SSH features, delegate only the ZFS permissions needed, and do not make this account your everyday way into the box.
Prove A Restore Before Trusting It
Backups do not matter because jobs run. They matter when restores work.
On backup-target, create a temporary restore area:
# Replace replica/restore-test if you use a different temporary restore dataset.
sudo zfs create -o mountpoint=/mnt/restore-test replica/restore-test
Clone a received snapshot:
# Replace the received snapshot and restore dataset names.
sudo zfs clone \
-o mountpoint=/mnt/restore-test/app-state \
replica/primary-nas/app-state@seed-2026-05 \
replica/restore-test/app-state
Inspect a harmless file or directory from the clone. Do not publish real filenames or screenshots from private data.
Clean up:
# Replace both restore dataset names if you used different names.
sudo zfs unmount replica/restore-test/app-state
sudo zfs destroy replica/restore-test/app-state
sudo zfs destroy replica/restore-test
That small restore check does not prove a whole disaster-recovery plan. It does prove that the target is doing more than collecting streams for decoration.
Before It Leaves The Building
Before calling the target ready to move, I used these checks:
- boot media documented
- data disk visible by stable
/dev/disk/by-id/path - receive pool imports after reboot
- NVMe remains visible after cold boot
- no undervoltage or throttling flags seen in the recorded boot
- initial full sends completed locally
- at least one restore spot-check completed
- cloud backup remains active
- no public services enabled
- Tailscale access works from the admin machine
After It Leaves The Building
The box is not offsite until it has moved and worked from the remote location. This one has done that; the details of the location and access path stay private.
After moving it, the checks are different:
- target boots at the remote location
- Tailscale access works from outside the home network
- an incremental send completes from
primary-nastobackup-target - a small restore check succeeds from the moved target
- alerts or manual checks confirm the target is reachable
- recovery notes explain what to do if the boot media fails
- recovery notes explain what to do if the data disk fails
Only after those checks is it an offsite replica. Before that, it is a locally seeded backup target waiting for the real test.
What This Does Not Replace
This does not replace cloud backup, and it does not turn one NVMe SSD into durable primary storage.
What it does is narrower and more useful: it gives selected data a second ZFS-based recovery path on hardware that is cheap enough, small enough, and boring enough to place somewhere else.
The Pi is disposable. The SD card is disposable. The OS install is disposable. The data copy, the receive path, and the restore test are what matter.
The box works because it refuses career progression. If it starts acquiring services, shares, dashboards, and clever little extras, it stops being backup infrastructure and becomes another liability. That is how the rot starts.