---
title: "Instant recovery is a clone you can promote"
description: "Instant recovery is a zfs clone of the snapshot you mean to keep, started as a new domain whose disks point at that clone. Promote the clone when that dataset is the one you will keep. The clone and the origin snapshot stay on the same pool."
url: https://howtobackupvm.com/en/blog/instant-recovery-zfs-clone/
---

- Home

- Guides

- Instant recovery is a clone you can promote

# Instant recovery is a clone you can promote

Instant recovery is a zfs clone of the snapshot you mean to keep, started as a new domain whose disks point at that clone. Promote the clone when that dataset is the one you will keep. The clone and the origin snapshot stay on the same pool.

Published 2026-11-07· HowToBackupVM editors

Instant recovery is a clone of the snapshot you intend to keep, with a new domain pointed at that clone. Promote that clone when it is the dataset you will keep. Until then it depends on the origin snapshot, and both live on the same pool. If that pool failed, there is nothing to clone.

A clone is not a second copy. A snapshot is not a backup. Lose the pool and you lose the clone. The copy you would use is the one that already arrived on another pool. The recovery point objective is the time since the last successful send.

## What is instant recovery?

The snapshot already holds the blocks. zfs clone makes a dataset of the same type whose initial contents are that snapshot. It is nearly immediate and starts with no extra space. Writes from the recovered guest land on the clone. Unchanged reads come from the origin snapshot.

Start a new libvirt domain against the clone. Leave the production domain on the production dataset. zfs rollback would discard newer writes on the live guest. A clone leaves those writes on the origin dataset.

The clone stays on this pool. A boot here does not prove the replica. Test a virtual machine restore on other storage if losing this pool is the failure you are testing.

On Proxmox, a linked clone of a ZFS disk is this dependency, and a full clone copies the disk. A VMware linked clone still dies with the datastore. Hyper-V merges a differencing disk by copying, which is not zfs promote. Back up Proxmox virtual machines. Back up VMware virtual machines. Back up Hyper-V virtual machines.

## Which snapshot do you clone?

Clone a snapshot, not a bookmark. Bookmarks let you delete dailies without breaking the next send. If only the bookmark remains, the blocks are on the replica. Receive that snapshot onto a pool that still works, then clone there.

zfs list -t snapshot -o name,creation,guid -s creation tank/vm-db

Use the snapshot whose creation time is the point you mean, and whose guid matches the one you wrote down. The newest name is wrong if the dataset was snapshotted again after the failure.

Clone each disk dataset from that same moment, including EFI if it is a separate dataset. A disk from 09:00 and firmware from yesterday is not a 09:00 VM. One dataset per VM is the job, the quota, and the key. Several guests that are one application need that same moment on every dataset. An application of several VMs shares one frozen moment.

The snapshot is crash-consistent unless you froze the guest first. Make application-consistent virtual machine backups. If the database is still replaying, do not promote and then destroy the origin. That destroy drops writes the origin still has from after the snapshot.

## How do you boot the clone?

For a filesystem dataset:

zfs clone -u -o mountpoint=none tank/vm-db@2026-11-07-0900 tank/vm-db-recover

-u skips the mount. mountpoint=none keeps the clone off the origin’s mountpoint. Skip -p. It creates missing parents and mounts them.

A zvol has no mountpoint. Clone it and read the device node, usually /dev/zvol/tank/vm-db-recover. For a file on a dataset, mount the clone somewhere the origin is not using and read that path back.

zfs get -H -o value origin tank/vm-db-recover should print tank/vm-db@2026-11-07-0900. - means this dataset is not a clone. zfs get -H -o value clones tank/vm-db@2026-11-07-0900 should list tank/vm-db-recover. If it does not, do not start the domain.

Copy the domain XML saved with the snapshot. The catalog is the domain XML saved with the snapshot. Change the name, the UUID, every disk source, and every interface before virsh define. libvirt will not define a second domain with the production UUID while the original is still defined.

Put the interface on a bridge with no uplink. A cleanroom is a clone on a bridge with no uplink. A first start on the production bridge lets the clone answer for the production address. Change the MAC before this domain shares a bridge with production. Before you start, the defined disk source is the clone, the bridge has no forward, and the name is not the production name.

## When do you promote?

zfs promote tank/vm-db-recover

Promote reverses the dependency. The origin dataset becomes a clone of the promoted dataset. The snapshot you cloned, and any older snapshots, move to the promoted clone. No new blocks are written, but their space is now accounted on the clone. Promote fails if the quota that must hold that space has no room, or if the clone already has a snapshot with one of those names. Rename the conflicting snapshot first.

Newer snapshots stay on the old dataset. Those are writes after the recovery point. List them before you destroy anything.

zfs list -t snapshot -o name,creation -s creation tank/vm-db

After promote, origin on tank/vm-db-recover is -, and origin on tank/vm-db is tank/vm-db-recover@2026-11-07-0900. The snapshot moved. A send that still names tank/vm-db@2026-11-07-0900 will not find it.

Leave the guest on the clone name if the XML already points there. To reuse the old name, shut the guest off, rename the old dataset aside, rename the clone, and point the XML at the new device path. Do not rename an open zvol. Save that XML with the snapshot you kept.

If the boot was only a look, do not promote. Shut the domain off, undefine it, and zfs destroy tank/vm-db-recover. The origin snapshot stays. Before promote, destroying the origin dataset fails because the clone still depends on it. After promote, destroying the old dataset is allowed and takes the newer snapshots with it. This is still the same pool, not an off-site copy. Keep an off-site virtual machine backup.

## What fails?

zfs destroy of the origin snapshot fails while clones lists the clone. -r and -f do not clear that. -R destroys the clone too. -d sets defer_destroy, so the snapshot disappears when you later destroy the test clone. Leave defer_destroy off if you mean to keep the point.

Destroying the old dataset after promote, without the list of snapshots that stayed on it, deletes writes made after the recovery point.

If the production pool is what failed, receive the replica onto other disks before you clone. A mirror in this machine is not another copy. A local mirror is the synchronous tier; remote send stays asynchronous. Three copies means three pools.

Do not add a qcow2 overlay on the clone. Keep protection snapshots under the disk, not in a qcow2 chain. If the database does not open, the snapshot was crash-consistent, or the freeze skipped the hook. Promote does not fix that.

## How do you prove it?

Before start, the clone’s origin names the snapshot you recorded, guid included, and clones on that snapshot lists the clone. The defined disk source is the clone path. The bridge has no uplink.

After start, the guest answers. A record written before the snapshot’s creation time is present. A record written on production after that time is absent. If the later record is there, you started production, or you cloned a later snapshot.

After promote, read origin on both datasets and list the snapshots again. Exit 0 from zfs promote is not that reading. A boot on this pool does not prove the replica.

The script builds its own fixtures. It does not run zfs, create a pool, or start a guest.

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

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

write_snap() {
  local base="$1"
  local dataset="$2"
  local snap="$3"
  local guid="$4"
  local txg="$5"
  local space="$6"
  mkdir -p "${base}/snaps"
  printf 'guid=%s\ntxg=%s\nspace=%s\nclones=-\n' "$guid" "$txg" "$space" > "${base}/snaps/${dataset}@${snap}"
}

write_ds() {
  local base="$1"
  local name="$2"
  local origin="$3"
  local quota="$4"
  mkdir -p "${base}/ds"
  printf 'origin=%s\nquota=%s\n' "$origin" "$quota" > "${base}/ds/${name}"
}

write_domain() {
  local base="$1"
  local name="$2"
  local uuid="$3"
  local disk="$4"
  local bridge="$5"
  printf 'name=%s\nuuid=%s\ndisk=%s\nbridge=%s\n' "$name" "$uuid" "$disk" "$bridge" > "${base}/domain"
}

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
}

set_field() {
  local file="$1"
  local key="$2"
  local value="$3"
  local tmp
  tmp="${file}.tmp"
  awk -v key="$key" -v value="$value" '
    BEGIN { found = 0 }
    index($0, key "=") == 1 { print key "=" value; found = 1; next }
    { print }
    END { if (!found) print key "=" value }
  ' "$file" > "$tmp"
  mv "$tmp" "$file"
}

seed_ready() {
  local base="$1"
  local quota="$2"
  mkdir -p "${base}/snaps" "${base}/ds" "${base}/bookmarks"
  write_ds "$base" vm-db - none
  write_ds "$base" vm-db-recover - "$quota"
  write_snap "$base" vm-db 2026-11-07-0800 108 8 20
  write_snap "$base" vm-db 2026-11-07-0900 109 9 40
  write_snap "$base" vm-db 2026-11-07-1000 110 10 15
  printf 'guid=109\n' > "${base}/bookmarks/vm-db#2026-11-07-0900"
  write_domain "$base" vm-db-recover recover-uuid /dev/zvol/tank/vm-db-recover isolated0
}

attach_clone() {
  local base="$1"
  local clone="$2"
  local origin="$3"
  local snap_file clones
  snap_file="${base}/snaps/${origin}"
  [[ -f "$snap_file" ]] || return 1
  [[ -f "${base}/ds/${clone}" ]] || return 1
  set_field "${base}/ds/${clone}" origin "$origin"
  clones="$(field "$snap_file" clones)"
  if [[ "$clones" == "-" ]]; then
    clones="$clone"
  else
    clones="${clones},${clone}"
  fi
  set_field "$snap_file" clones "$clones"
}

can_clone_source() {
  local base="$1"
  local kind="$2"
  local name="$3"
  if [[ "$kind" == "bookmark" ]]; then
    return 1
  fi
  [[ "$kind" == "snapshot" ]] || return 1
  [[ -f "${base}/snaps/${name}" ]]
}

listed_clone() {
  local snap_file="$1"
  local clone="$2"
  local clones
  clones="$(field "$snap_file" clones)"
  [[ ",${clones}," == *",${clone},"* ]]
}

can_boot() {
  local base="$1"
  local origin snap_file disk bridge name uuid
  [[ -f "${base}/ds/vm-db-recover" ]] || return 1
  [[ -f "${base}/domain" ]] || return 1
  origin="$(field "${base}/ds/vm-db-recover" origin)"
  [[ "$origin" == "vm-db@2026-11-07-0900" ]] || return 1
  snap_file="${base}/snaps/${origin}"
  [[ -f "$snap_file" ]] || return 1
  listed_clone "$snap_file" vm-db-recover || return 1
  disk="$(field "${base}/domain" disk)"
  [[ "$disk" == "/dev/zvol/tank/vm-db-recover" ]] || return 1
  bridge="$(field "${base}/domain" bridge)"
  [[ "$bridge" == "isolated0" ]] || return 1
  name="$(field "${base}/domain" name)"
  [[ "$name" != "vm-db" ]] || return 1
  uuid="$(field "${base}/domain" uuid)"
  [[ "$uuid" != "prod-uuid" ]] || return 1
}

can_destroy_snapshot() {
  local base="$1"
  local snap="$2"
  local mode="$3"
  local clones
  [[ "$mode" == "normal" ]] || return 1
  [[ -f "${base}/snaps/${snap}" ]] || return 1
  clones="$(field "${base}/snaps/${snap}" clones)"
  [[ "$clones" == "-" ]]
}

moving_space() {
  local base="$1"
  local origin_ds="$2"
  local cut="$3"
  local file txg space total
  total=0
  for file in "${base}/snaps/${origin_ds}"@*; do
    [[ -f "$file" ]] || continue
    txg="$(field "$file" txg)"
    if [[ "$txg" -le "$cut" ]]; then
      space="$(field "$file" space)"
      total=$((total + space))
    fi
  done
  printf '%s\n' "$total"
}

name_conflict() {
  local base="$1"
  local origin_ds="$2"
  local clone="$3"
  local cut="$4"
  local file short txg
  for file in "${base}/snaps/${origin_ds}"@*; do
    [[ -f "$file" ]] || continue
    txg="$(field "$file" txg)"
    [[ "$txg" -le "$cut" ]] || continue
    short="${file##*@}"
    [[ -f "${base}/snaps/${clone}@${short}" ]] && return 0
  done
  return 1
}

can_promote() {
  local base="$1"
  local clone="$2"
  local origin cut quota need
  [[ -f "${base}/ds/${clone}" ]] || return 1
  origin="$(field "${base}/ds/${clone}" origin)"
  [[ "$origin" == *"@"* ]] || return 1
  [[ -f "${base}/snaps/${origin}" ]] || return 1
  cut="$(field "${base}/snaps/${origin}" txg)"
  name_conflict "$base" "${origin%@*}" "$clone" "$cut" && return 1
  quota="$(field "${base}/ds/${clone}" quota)"
  if [[ "$quota" != "none" ]]; then
    need="$(moving_space "$base" "${origin%@*}" "$cut")"
    [[ "$need" -le "$quota" ]] || return 1
  fi
}

apply_promote() {
  local base="$1"
  local clone="$2"
  local origin origin_ds cut file short txg
  can_promote "$base" "$clone"
  origin="$(field "${base}/ds/${clone}" origin)"
  origin_ds="${origin%@*}"
  cut="$(field "${base}/snaps/${origin}" txg)"
  for file in "${base}/snaps/${origin_ds}"@*; do
    [[ -f "$file" ]] || continue
    txg="$(field "$file" txg)"
    [[ "$txg" -le "$cut" ]] || continue
    short="${file##*@}"
    mv "$file" "${base}/snaps/${clone}@${short}"
  done
  set_field "${base}/snaps/${clone}@${origin#*@}" clones "$origin_ds"
  set_field "${base}/ds/${origin_ds}" origin "${clone}@${origin#*@}"
  set_field "${base}/ds/${clone}" origin -
}

newer_left_behind() {
  local base="$1"
  local dataset="$2"
  local cut="$3"
  local file txg short
  for file in "${base}/snaps/${dataset}"@*; do
    [[ -e "$file" ]] || continue
    [[ -f "$file" ]] || continue
    txg="$(field "$file" txg)"
    if [[ "$txg" -gt "$cut" ]]; then
      short="${file##*@}"
      printf '%s\n' "$short"
    fi
  done
}

safe_to_destroy_old() {
  local base="$1"
  local dataset="$2"
  local ack="$3"
  local origin left
  origin="$(field "${base}/ds/${dataset}" origin)"
  [[ "$origin" == "vm-db-recover@2026-11-07-0900" ]] || return 1
  left="$(newer_left_behind "$base" "$dataset" 9 || true)"
  if [[ -n "$left" ]]; then
    [[ -f "$ack" ]] || return 1
    [[ "$(cat "$ack")" == "$left" ]] || return 1
  fi
}

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

ready="${root}/ready"
seed_ready "$ready" none
can_clone_source "$ready" snapshot "vm-db@2026-11-07-0900"
must_fail "a bookmark as a clone source" \
  can_clone_source "$ready" bookmark "vm-db#2026-11-07-0900"
attach_clone "$ready" vm-db-recover "vm-db@2026-11-07-0900"
can_boot "$ready"
must_fail "destroying the origin snapshot while the clone lists it" \
  can_destroy_snapshot "$ready" "vm-db@2026-11-07-0900" normal
must_fail "destroy -R, which would take the clone with the snapshot" \
  can_destroy_snapshot "$ready" "vm-db@2026-11-07-0900" recursive
can_promote "$ready" vm-db-recover
apply_promote "$ready" vm-db-recover
[[ "$(field "${ready}/ds/vm-db-recover" origin)" == "-" ]]
[[ "$(field "${ready}/ds/vm-db" origin)" == "vm-db-recover@2026-11-07-0900" ]]
[[ -f "${ready}/snaps/vm-db-recover@2026-11-07-0900" ]]
[[ -f "${ready}/snaps/vm-db-recover@2026-11-07-0800" ]]
[[ -f "${ready}/snaps/vm-db@2026-11-07-1000" ]]
[[ ! -f "${ready}/snaps/vm-db@2026-11-07-0900" ]]
printf '2026-11-07-1000\n' > "${ready}/ack"
safe_to_destroy_old "$ready" vm-db "${ready}/ack"
must_fail "destroying the old dataset without listing the newer snapshot" \
  safe_to_destroy_old "$ready" vm-db "${ready}/missing-ack"

wrong_disk="${root}/wrong-disk"
seed_ready "$wrong_disk" none
attach_clone "$wrong_disk" vm-db-recover "vm-db@2026-11-07-0900"
set_field "${wrong_disk}/domain" disk /dev/zvol/tank/vm-db
must_fail "a domain whose disk is still the origin" \
  can_boot "$wrong_disk"

prod_bridge="${root}/prod-bridge"
seed_ready "$prod_bridge" none
attach_clone "$prod_bridge" vm-db-recover "vm-db@2026-11-07-0900"
set_field "${prod_bridge}/domain" bridge br0
must_fail "a domain on the production bridge" \
  can_boot "$prod_bridge"

tight="${root}/tight-quota"
seed_ready "$tight" 10
attach_clone "$tight" vm-db-recover "vm-db@2026-11-07-0900"
must_fail "a promote whose quota cannot account for the snapshots that move" \
  can_promote "$tight" vm-db-recover

clash="${root}/name-clash"
seed_ready "$clash" none
attach_clone "$clash" vm-db-recover "vm-db@2026-11-07-0900"
write_snap "$clash" vm-db-recover 2026-11-07-0800 208 1 1
must_fail "a clone snapshot whose name matches one promote would move" \
  can_promote "$clash" vm-db-recover

plain="${root}/not-a-clone"
seed_ready "$plain" none
must_fail "promoting a dataset that is not a clone" \
  can_promote "$plain" vm-db-recover

printf 'Clone-promote check passed.\n'
printf 'Proves: a clone of the named snapshot can be booted only from the clone path on an isolated bridge; the origin snapshot cannot be destroyed while that clone lists it; promote moves that snapshot and older ones onto the clone, leaves newer origin snapshots in place, and refuses a tight quota or a snapshot-name clash.\n'
printf 'Does not prove: zfs clone or zfs promote ran, a guest booted, or the pool holds a second copy.\n'
```

## Questions

A zfs clone of the snapshot you mean to keep, and a new domain whose disks point at that clone. Promote only when that clone is the dataset you will keep. Until then the clone depends on the origin snapshot, and both are on the same pool.

No. Promote reverses the dependency. The snapshot you cloned, and older snapshots, become snapshots of the promoted dataset. No new blocks are written. The pool is still the only copy.

No. zfs destroy fails while the snapshot's clones property lists the clone. -r and -f do not clear that. -R destroys the clone as well. -d marks the snapshot to disappear when the clone is later destroyed.

No. It shows this pool can run that snapshot under a new name, on a bridge with no uplink. It does not show that a copy on another pool boots. Test that copy on other storage.

## Sitemap

- [HTML](https://howtobackupvm.com/en/blog/instant-recovery-zfs-clone/): 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
