---
title: "One dataset per virtual machine is the job, the quota, and the key"
description: "A backup per virtual machine is a ZFS dataset that holds only that guest. Snapshot and send that dataset. Its refquota or volsize is the cap, and its encryption root is the key."
url: https://howtobackupvm.com/en/blog/one-dataset-per-vm/
---

- Home

- Guides

- One dataset per virtual machine is the job, the quota, and the key

# One dataset per virtual machine is the job, the quota, and the key

A backup per virtual machine is a ZFS dataset that holds only that guest. Snapshot and send that dataset. Its refquota or volsize is the cap, and its encryption root is the key.

Published 2026-11-11· HowToBackupVM editors

Put each virtual machine on its own dataset. That dataset is the backup job, the cap on its disk, and its encryption key. Snapshot it and send it. A dataset that holds every guest’s files is one recovery point for all of them.

On VMware that job copies one VM’s disks, not the datastore. On Hyper-V it copies one VM’s files, not the CSV. Back up VMware virtual machines. Back up Hyper-V virtual machines.

## What is a backup per virtual machine?

A backup per virtual machine names one guest. tank/vm/db holds db’s disks. zfs snapshot tank/vm/db@2026-11-11 is db’s recovery point, not app’s.

Veeam keeps one backup chain per machine so one guest can expire without locking the job. A dataset is that split.

A folder of qcow2 files on tank/images is one dataset. One snapshot, one send, and one destroy apply to every file in it. A snapshot is not a backup. The send is what leaves the pool, and the dataset decides what that send contains.

zfs snapshot tank/vm@2026-11-11 without -r does not include tank/vm/db. Child datasets are not files in the parent. The two mistakes are putting every disk file on one filesystem, and creating or destroying the children under one recursive name.

## Where does the disk live?

tank/vm is a container. zfs create -o mountpoint=none -o canmount=off tank/vm does not create a disk. Skip -p on that command. -o applies to tank/vm. Ancestors created by -p ignore those properties.

A block device is a volume: zfs create -V 40G tank/vm/db. The guest sees /dev/zvol/tank/vm/db. volsize is the size the guest sees. That create also sets a refreservation, which keeps the volume thick. The reserved space is at least volsize, and it includes room for metadata. zfs create -s -V 40G is sparse. A write can return ENOSPC when the pool is low, including after a restore onto a pool that did not reserve the space. Skip -s unless you have measured free space there. volblocksize is fixed at create.

A file disk is zfs create -o mountpoint=/var/lib/libvirt/images/db tank/vm/db. Point the domain at a file on that mount. Two domains on one mount are one job.

Proxmox ZFS storage already creates one zvol per disk, such as rpool/data/vm-100-disk-0. That is one dataset per disk, which is finer than one dataset per VM. Snapshot the zvols for that VMID. Do not run zfs snapshot -r rpool/data@night. -r snapshots every descendant dataset, volumes included, at the same time. Back up Proxmox virtual machines.

The domain XML is not in the dataset. Save it with the snapshot. The catalog is the domain XML saved with the snapshot. Two files on one filesystem dataset share one snapshot. Two zvols are two snapshots. Take them in the same freeze if the guest must see both disks at one moment. Make application-consistent virtual machine backups.

## What is the quota?

refquota limits space a filesystem dataset itself references. Snapshots and children are outside it. zfs set refquota=45G tank/vm/db stops that guest’s live files. The others keep writing.

quota limits the dataset and its descendants, including snapshots. A parent quota is a second cap, not a substitute for the child’s refquota. A child’s refreservation counts against the parent quota. A full parent stops every child. Leave quota unset on tank/vm unless the number includes snapshot growth.

quota cannot be set on a volume. volsize is the implicit quota. Read zfs get -H -o value volsize tank/vm/db and zfs get -H -o value refreservation tank/vm/db. If refreservation is none, or smaller than volsize, treat the volume as sparse. refreservation=auto is the thick setting.

refquota does not stop snapshot growth. Overwritten blocks stay in the snapshot and can fill the pool while the guest sits at the cap. zfs get -H -o value usedbysnapshots tank/vm/db is that growth. Choose backup retention for virtual machines.

## Which dataset holds the key?

Set encryption at create. zfs set encryption=on on an existing dataset fails. Create a new encrypted dataset, copy the disk, and change the domain only after that copy boots.

zfs create -o encryption=on -o keyformat=passphrase -o keylocation=file:///etc/zfs/keys/vm-db.key tank/vm/db

encryption=on selects aes-256-gcm, and that cipher cannot change later. zfs change-key can change the key and the key format. zfs set can change keylocation.

zfs get -H -o value encryptionroot tank/vm/db must print tank/vm/db. If it prints tank or tank/vm, this guest inherits that key. zfs load-key and zfs unload-key on the encryption root load and unload every inheriting dataset. One passphrase reads every guest.

keylocation exists only on the encryption root. Do not put the key file on the dataset it unlocks. Set the file to mode 0600, and keep a copy off this pool. A clone uses the origin’s key. Promote does not create a new one. Instant recovery is a clone you can promote.

zfs send -w copies ciphertext without the key loaded. A send without -w decrypts on the way out. The receiver may re-encrypt under a different key, and a later raw incremental to that copy fails. If the first send was raw, keep -w. A replication stream of encrypted datasets also requires -w.

## What command is the job?

Freeze the guest if it needs a consistent point, then zfs snapshot tank/vm/db@2026-11-11. The first copy is zfs send tank/vm/db@2026-11-11. Later copies start at the bookmark of the snapshot the replica already has. Bookmarks let you delete dailies without breaking the next send. Destroy the local daily only after that name is the replica’s latest snapshot of this dataset.

zfs snapshot -r tank/vm@2026-11-11 still lets you send one child, but every child gets that snapshot name. zfs destroy -r tank/vm@2026-11-11 removes it on every descendant that has it, so every guest loses that day. Use one shared time only when several guests must match. Do not make that the nightly job for every guest.

zfs send -R tank/vm@2026-11-11 is not a backup of one virtual machine. -R packages the named dataset and descendant file systems. The receive keeps properties, snapshots, descendants, and clones. The man page names file systems, not volumes, so read the names. zfs send -Rnv tank/vm@2026-11-11 prints them and writes no stream. If a second guest appears, send tank/vm/db@2026-11-11 instead.

zfs receive -F of a replication stream destroys destination datasets the sender no longer has. The replica keeps snapshots the source is no longer allowed to expire.

This guest’s recovery point is the age of the last snapshot of this dataset that arrived, not a sibling’s. The recovery point objective is the time since the last successful send.

## What fails?

A parent quota as the only cap stops the first guest that grows, or the first thick zvol whose reservation counts against it. The child’s own properties do not show the parent cap.

A sparse zvol can boot and then fail the next write with ENOSPC, because the receiving pool never reserved volsize.

A domain XML that still points at /dev/zvol/tank/images/db starts the wrong disk, or does not start, even when the receive is the right dataset.

Two domains on one zvol share one snapshot. You cannot tell which domain it belongs to.

A key file on the dataset it unlocks, or keylocation=prompt on a host that reboots with nobody at the console, leaves the dataset locked. A non-raw send waits for the passphrase. A raw send can still run. The replica cannot boot the guest until you load the key on a host you will let read the disk.

zfs set quota=40G on a zvol fails. Clearing refreservation after that leaves the volume sparse.

## How do you prove the restore was this guest?

Receive only tank/vm/db@2026-11-11 onto a pool that does not hold production. tank/vm/app is not in that receive. The domain disk path is /dev/zvol/tank/vm/db, or a file on that dataset. Boot on a bridge with no uplink. Test a virtual machine restore. A cleanroom is a clone on a bridge with no uplink.

Write down the dataset name, zfs get -H -o value guid tank/vm/db@2026-11-11, encryptionroot, volsize or refquota, and the disk path before that boot. If encryptionroot is not this dataset, the guest still depends on a key you did not mean to load.

A clone on this pool shows the snapshot is readable here. It does not show that another pool has only this guest. A receive in this building is not the off-site copy. Keep an off-site virtual machine backup.

These scripts build their own fixtures. They do not run zfs, create a pool, or send a stream. The first accepts a dataset as a per-VM job only when it names one guest, has no children, is its own encryption root, and is capped: a volume whose refreservation covers volsize, or a filesystem with a refquota.

```text
#!/usr/bin/env bash
set -euo pipefail

root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT

write_ds() {
  local file="$1"
  local name="$2"
  local type="$3"
  local guests="$4"
  local encryptionroot="$5"
  local volsize="$6"
  local refreservation="$7"
  local refquota="$8"
  local children="$9"
  mkdir -p "$(dirname "$file")"
  printf 'name=%s\ntype=%s\nguests=%s\nencryptionroot=%s\nvolsize=%s\nrefreservation=%s\nrefquota=%s\nchildren=%s\n' \
    "$name" "$type" "$guests" "$encryptionroot" "$volsize" "$refreservation" "$refquota" "$children" > "$file"
}

field() {
  local file="$1"
  local key="$2"
  local line
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" == "${key}="* ]]; then
      printf '%s\n' "${line#"${key}="}"
      return 0
    fi
  done < "$file"
  printf 'missing %s in %s\n' "$key" "$file" >&2
  return 1
}

is_integer() {
  [[ "$1" =~ ^[0-9]+$ ]]
}

guest_count() {
  local guests="$1"
  local part
  local count=0
  [[ -n "$guests" ]] || {
    printf '0\n'
    return 0
  }
  IFS=',' read -r -a parts <<< "$guests"
  for part in "${parts[@]}"; do
    [[ -n "$part" ]] || continue
    count=$((count + 1))
  done
  printf '%s\n' "$count"
}

is_own_key() {
  local file="$1"
  local name encryptionroot
  name="$(field "$file" name)"
  encryptionroot="$(field "$file" encryptionroot)"
  [[ "$encryptionroot" == "$name" ]]
}

is_capped() {
  local file="$1"
  local type volsize refreservation refquota
  type="$(field "$file" type)"
  volsize="$(field "$file" volsize)"
  refreservation="$(field "$file" refreservation)"
  refquota="$(field "$file" refquota)"
  if [[ "$type" == "volume" ]]; then
    is_integer "$volsize" || return 1
    is_integer "$refreservation" || return 1
    [[ "$volsize" -gt 0 && "$refreservation" -ge "$volsize" ]]
  elif [[ "$type" == "filesystem" ]]; then
    is_integer "$refquota" || return 1
    [[ "$refquota" -gt 0 ]]
  else
    return 1
  fi
}

is_per_vm_job() {
  local file="$1"
  local guests children count
  guests="$(field "$file" guests)"
  children="$(field "$file" children)"
  [[ -z "$children" ]] || return 1
  count="$(guest_count "$guests")"
  [[ "$count" -eq 1 ]] || return 1
  is_own_key "$file" || return 1
  is_capped "$file"
}

must_fail() {
  local label="$1"
  shift
  if "$@"; then
    printf 'checker accepted %s\n' "$label" >&2
    exit 1
  fi
}

good_vol="${root}/good-volume"
write_ds "$good_vol" tank/vm/db volume db tank/vm/db 42949672960 42949672960 none ""
is_per_vm_job "$good_vol"

good_fs="${root}/good-fs"
write_ds "$good_fs" tank/vm/app filesystem app tank/vm/app none none 48318382080 ""
is_per_vm_job "$good_fs"

shared="${root}/shared"
write_ds "$shared" tank/images filesystem "db,app" tank none none none ""
must_fail "a dataset that holds two guests" is_per_vm_job "$shared"

inherited="${root}/inherited-key"
write_ds "$inherited" tank/vm/db volume db tank/vm 42949672960 42949672960 none ""
must_fail "a guest whose encryption root is the parent" is_per_vm_job "$inherited"

sparse="${root}/sparse"
write_ds "$sparse" tank/vm/db volume db tank/vm/db 42949672960 0 none ""
must_fail "a sparse volume" is_per_vm_job "$sparse"

uncapped="${root}/uncapped-fs"
write_ds "$uncapped" tank/vm/db filesystem db tank/vm/db none none none ""
must_fail "a filesystem with no refquota" is_per_vm_job "$uncapped"

parent="${root}/parent"
write_ds "$parent" tank/vm filesystem "" tank/vm none none none "db,app"
must_fail "a parent that only names children" is_per_vm_job "$parent"

printf 'Dataset-job check passed.\n'
printf 'Proves: a volume with its own key and a refreservation covering volsize is a per-VM job, and so is a filesystem with its own key and a refquota. A shared guest list, an inherited key, a sparse volume, a missing refquota, and a parent container are rejected.\n'
printf 'Does not prove: zfs create ran, a quota was enforced, or a guest boots.\n'
```

The second script checks send scope and the restore record. A stream that lists only tank/vm/db is one guest. A parent replication stream is not. A shared snapshot name sits on both children, which is why zfs destroy -r of that name is not a per-VM expire. A restore counts only when the receive and the domain disk path are that dataset.

```text
#!/usr/bin/env bash
set -euo pipefail

root="$(mktemp -d)"
trap 'rm -rf "$root"' EXIT

write_snap() {
  local file="$1"
  local dataset="$2"
  local snap="$3"
  local guid="$4"
  mkdir -p "$(dirname "$file")"
  printf 'dataset=%s\nsnap=%s\nguid=%s\n' "$dataset" "$snap" "$guid" > "$file"
}

field() {
  local file="$1"
  local key="$2"
  local line
  while IFS= read -r line || [[ -n "$line" ]]; do
    if [[ "$line" == "${key}="* ]]; then
      printf '%s\n' "${line#"${key}="}"
      return 0
    fi
  done < "$file"
  printf 'missing %s in %s\n' "$key" "$file" >&2
  return 1
}

seed_pool() {
  local base="$1"
  mkdir -p "${base}/snapshots" "${base}/streams" "${base}/received" "${base}/domains"
  write_snap "${base}/snapshots/tank_vm_db@2026-11-11" tank/vm/db 2026-11-11 1101
  write_snap "${base}/snapshots/tank_vm_app@2026-11-11" tank/vm/app 2026-11-11 1102
  write_snap "${base}/snapshots/tank_vm@2026-11-11" tank/vm 2026-11-11 1100
  printf 'tank/vm/db\n' > "${base}/streams/per-vm"
  printf 'tank/vm\ntank/vm/db\ntank/vm/app\n' > "${base}/streams/replicate-parent"
  printf 'disk=/dev/zvol/tank/vm/db\n' > "${base}/domains/db.xml"
  printf 'tank/vm/db\n' > "${base}/received/names"
}

stream_is_one_guest() {
  local base="$1"
  local stream="$2"
  local guest="$3"
  local file="${base}/streams/${stream}"
  local count=0
  local line
  [[ -f "$file" ]] || return 1
  while IFS= read -r line || [[ -n "$line" ]]; do
    [[ -n "$line" ]] || continue
    count=$((count + 1))
    [[ "$line" == "$guest" ]] || return 1
  done < "$file"
  [[ "$count" -eq 1 ]]
}

recursive_destroy_removes_both() {
  local base="$1"
  local snap="$2"
  local db="${base}/snapshots/tank_vm_db@${snap}"
  local app="${base}/snapshots/tank_vm_app@${snap}"
  [[ -f "$db" && -f "$app" ]] || return 1
  [[ "$(field "$db" snap)" == "$snap" && "$(field "$app" snap)" == "$snap" ]]
}

restore_is_this_guest() {
  local base="$1"
  local guest="$2"
  local names="${base}/received/names"
  local domain="${base}/domains/db.xml"
  local line count=0
  [[ -f "$names" && -f "$domain" ]] || return 1
  while IFS= read -r line || [[ -n "$line" ]]; do
    [[ -n "$line" ]] || continue
    count=$((count + 1))
    [[ "$line" == "$guest" ]] || return 1
  done < "$names"
  [[ "$count" -eq 1 ]] || return 1
  grep -q "disk=/dev/zvol/${guest}" "$domain"
}

must_fail() {
  local label="$1"
  shift
  if "$@"; then
    printf 'checker accepted %s\n' "$label" >&2
    exit 1
  fi
}

pool="${root}/pool"
seed_pool "$pool"
stream_is_one_guest "$pool" per-vm tank/vm/db
recursive_destroy_removes_both "$pool" 2026-11-11
restore_is_this_guest "$pool" tank/vm/db

must_fail "a parent replication stream" \
  stream_is_one_guest "$pool" replicate-parent tank/vm/db

sibling="${root}/sibling-received"
seed_pool "$sibling"
printf 'tank/vm/db\ntank/vm/app\n' > "${sibling}/received/names"
must_fail "a receive that also has the sibling" \
  restore_is_this_guest "$sibling" tank/vm/db

wrong_disk="${root}/wrong-disk"
seed_pool "$wrong_disk"
printf 'disk=/dev/zvol/tank/images/db\n' > "${wrong_disk}/domains/db.xml"
must_fail "a domain whose disk is not this dataset" \
  restore_is_this_guest "$wrong_disk" tank/vm/db

printf 'Send-scope check passed.\n'
printf 'Proves: a stream that names only tank/vm/db is one guest, a parent replication stream is not, a shared snapshot name is on both children, and a restore counts only when the receive and the domain disk path are that dataset.\n'
printf 'Does not prove: zfs send ran, zfs receive committed, or the guest boots.\n'
```

## Questions

A snapshot and a send of the dataset that holds only that guest. A dataset full of every guest's qcow2 files is one job. A non-recursive snapshot of the parent does not include child datasets.

No. quota cannot be set on a volume. volsize is the size the guest sees, and refreservation is the space the pool holds. If refreservation is smaller than volsize, the volume is sparse and a write can fail with ENOSPC.

zfs get -H -o value encryptionroot tank/vm/db must print tank/vm/db. If it prints the parent, zfs load-key on that parent unlocks every inheriting dataset. Encryption cannot be turned on after the dataset exists.

No. -r snapshots every descendant dataset at the same time. You can still send one child. zfs destroy -r of that snapshot name removes it on every child that has the name. The per-VM job snapshots and expires that child alone.

On another pool, the receive lists that dataset and not the sibling, the domain XML disk path is that dataset, and the guest boots on a bridge with no uplink. A clone on the production pool does not prove the other pool.

## Sitemap

- [HTML](https://howtobackupvm.com/en/blog/one-dataset-per-vm/): the page this markdown mirrors
- [llms.txt](https://howtobackupvm.com/llms.txt): curated index of markdown pages
- [Sitemap](https://howtobackupvm.com/sitemap.md): every public page
