THE FINE PRINT, ENLARGED

What it is

A manual? At two in the morning? With TABLES?
GGary from Opsreading anyway

decom.sh is a single bash script for taking a Linux server out of service. It has three phases, and you run them in order:

  1. inventory lists what lived on the host: processes, services, their configs and state, environment secrets, users' keys and dotfiles. It writes a directory and a tarball.
  2. checklist turns the inventory into ROTATION-CHECKLIST.md, the credentials and identities you must rotate or revoke.
  3. wipe destroys the data on whole disks and proves, by reading every byte back, that they hold nothing but zeros. It ends with a receipt.

The wipe never runs on its own. decom.sh all runs the first two phases and stops, and prints the wipe command for you to run yourself once the inventory is off the machine.

The reasoning behind the order: no tool inside a machine can prove that every copy of its data is gone. Provider snapshots, storage-layer copies and retired SSD cells are out of reach. So the wipe does what can be proven, the receipt says what that was, and the checklist makes anything recovered from elsewhere worthless.

Get it

It is one bash script. The source, the docs and the VM lab are at git.itys.net/mjh/decom.

# curl -fsSLo decom.sh https://git.itys.net/mjh/decom/raw/branch/main/decom.sh
# chmod +x decom.sh
# ./decom.sh selftest        # no root, touches no disk

Or clone the whole repository: git clone https://git.itys.net/mjh/decom.git. Read the script before you run it as root. It is meant to destroy data.

Quick start

Boot the server into the provider's rescue system if it has one (see rescue mode or in place). As root:

# decom.sh inventory --out /root/decom.out
# decom.sh checklist --out /root/decom.out
# scp -r /root/decom.out /root/decom.out.tar.gz you@elsewhere:
# decom.sh wipe --target /dev/sda --target /dev/sdb --out /root/decom.out
  1. The wipe lists every target with its model, serial and size and asks you to type the device names (sda sdb).
  2. It starts itself as the systemd unit decom-wipe and your terminal follows its log. If the SSH session drops, the wipe continues; reconnect and run journalctl -fu decom-wipe or tail -f /run/decom.*/decom.log.
  3. When it finishes, copy the receipt (/run/decom.*/RECEIPT.txt, also printed on the console) off the machine. After an in-place wipe a new SSH login usually fails, so add --serve or --send-receipt to the wipe; see getting the receipt off the machine.
  4. Work through ROTATION-CHECKLIST.md, delete the provider's snapshots and backups of the server, and destroy the instance.

The inventory directory holds secrets: it copies process environments, which is where API keys and database passwords tend to live. It is created readable by root only (directory 700, tarball 600); if --out names an existing directory others can read, the script warns. Move it somewhere safe, and shred it once rotation is done.

Requirements

  • Linux, bash 4 or later, run as root for inventory and wipe. selftest and --help need no root.
  • For the wipe: coreutils (dd, od), diffutils (cmp), util-linux (lsblk, blockdev, blkdiscard, wipefs), e2fsprogs (filefrag), findutils.
  • Recommended: a static busybox. The wipe copies it into RAM alongside everything else.
  • For --serve: a static busybox with the httpd applet. For --send-receipt: curl, and for https a CA bundle. Each option is refused before the confirmation if what it needs is missing.
  • Optional, used when present: nvme-cli (NVMe sanitize and format), hdparm (ATA secure erase, HPA check), sg3-utils (SCSI sanitize), mdadm and dmsetup (stopping RAID and device-mapper devices built on the targets). A step whose tool is missing is recorded as unsupported, never silently skipped.
  • systemd for the detached run. Without it the wipe detaches with setsid.

inventory

decom.sh inventory [--out DIR]. Default output: /tmp/decom.<timestamp> plus a .tar.gz of it.

  • Processes: for each running process, its executable, working directory, command line, environment (marked "secrets likely") and open files.
  • systemd services: each unit's file, drop-ins, environment files, working, state, configuration, log and cache directories.
  • Packages: package-owned config files, which of them were modified, and files written since the system was provisioned.
  • Containers: if Docker is installed, each container's mounts and environment.
  • Users: each user's .ssh, .gnupg, cloud credentials, shell history, .netrc, .pgpass and similar.
  • Always sensitive: /etc/shadow, /etc/ssh, /etc/ssl/private, /etc/letsencrypt, cloud-init state, the journal, swap.

Every path it finds goes into meta/paths.txt. The wipe reads that list for its per-file proof when you pass the same --out.

checklist

decom.sh checklist [--out DIR] reads the inventory and writes ROTATION-CHECKLIST.md in it, with checkboxes for:

  • SSH host keys to remove from clients' known_hosts, user keys to rotate, SSH certificates to revoke;
  • TLS certificates to revoke (not just reissue) and ACME account keys;
  • every environment variable on a running process whose name looks like a secret (KEY, TOKEN, SECRET, PASS, DSN and so on), listed by name;
  • database passwords, when a database was running;
  • the host's entries in directories, monitoring, config management and DNS, and its access to backup storage;
  • provider-side work: delete every snapshot and image of the server, then destroy it.

wipe, step by step

decom.sh wipe --target /dev/DISK [--target ...] [options]. The steps run in this order:

  1. Check the targets

    Each --target is resolved (symlinks such as /dev/disk/by-id/... are followed) and must be a whole disk. Partitions, LVM and device-mapper volumes, RAID devices, /dev/mapper names and duplicates are refused; loop devices only with --allow-loop. If any disk on the machine is not a target, the wipe refuses and lists it, unless you pass --allow-partial. With hardware erase on, every namespace of a targeted NVMe controller must also be a target, because a sanitize erases them all.

  2. Confirm

    The prompt shows each target's model, serial and size. You type the device names. Without a terminal on stdin, --yes is required instead. --serve and --send-receipt are checked before this prompt, and the receipt URL is printed above it, on your terminal and on the console.

  3. Copy into RAM and self-test

    bash, the script, busybox and every tool the wipe calls are copied into a fresh tmpfs with their shared libraries and loader, so nothing afterwards runs from the disks being wiped. A self-test runs there: the tools start, the verify catches a short read and a non-zero byte, normalize zeroes a test file. If anything fails, the wipe stops before touching a disk.

  4. Detach

    The wipe runs as the systemd unit decom-wipe (or under setsid), with a locale and time zone that need nothing from disk. Your terminal follows the log and exits with the verdict. With --serve, the receipt server starts now, from the tmpfs, as its own unit decom-serve.

  5. Map the sensitive files

    While the filesystems are still mounted, each sensitive file on a target is mapped to its extents on the disk. See per-file proof.

  6. Quiesce

    Swap off, then a check that no swap remains on a target. Services that hold files open for writing on the targets are stopped, and journald is told to let go of /var. Then sync, an emergency read-only remount of every filesystem, the partitions under the target mounts set read-only at the block layer, and RAID, device-mapper and bcache devices built on the targets stopped from the top down. Anything still writable, swapped to, or stacked on a target makes the verdict FAIL.

  7. Hardware erase, then wait

    Every target is hardware-erased before any of them is written. NVMe: sanitize with crypto erase, then block erase, sent once per controller, with the sanitize log polled until it finishes (--hw-timeout); if sanitize is unsupported, an NVMe format with secure erase. ATA: secure erase, unless the drive is frozen or already has a password, which is recorded as such. If an ATA erase fails after a password was set, the password is removed again. SCSI and SAS: sanitize where supported. Virtual disks have no hardware erase and say so.

  8. For each target: HPA check and wipefs

    On ATA disks, a host protected area that hides sectors fails the run, with the hdparm command that removes it. Then wipefs clears filesystem and partition signatures.

  9. Normalize to zero

    Every block is read and zeros are written only where a block is not already zero. See normalize.

  10. Fence (in place only)

    When the target is the running root disk, the whole disk is set read-only right after it is normalized, so the running kernel cannot write to it before the verify.

  11. Verify

    Every byte is read back with direct I/O and compared with zeros. It passes only if exactly the device's size was read and every byte was zero. A read error or a short read is a FAIL. With --sample N, only the first block, the last block and N random 1 MiB blocks across the whole disk are read, and the receipt says the verify was sampled.

  12. Re-read the file extents and signatures

    The mapped file extents are read again and must be zero. After a 10 second settle, the offsets where a signature or partition start was found before the wipe are read again too.

  13. Reclaim (only if asked)

    With --reclaim, after every target has verified: discard, then verify again. See reclaim.

  14. Receipt

    Every step and its outcome, written to the console, the log and RECEIPT.txt in the tmpfs, with its SHA-256 on the console. With --serve it is published at the receipt URL; with --send-receipt it is uploaded once. See the receipt and getting it off the machine.

Normalize to zero

I only kept the zeros that cover my backing file. The old data is IN the backing file. Delete it too, or it's a snapshot with extra steps!
QA qcow2 overlayknows where the bodies are

Normalize brings every block of a target to zero and leaves alone the blocks that already read zero. It does not write every block.

It reads the disk in 16 MiB chunks with direct I/O. A chunk that reads all zero costs one read and one compare. When a chunk holds data, it finds each 64 KiB piece that is not zero, joins neighbors into a single write, and writes zeros over them in place. Every read and write is checked for its exit status and byte count.

When the disk has bad spots

A 16 MiB chunk that cannot be read is zeroed whole, since its contents are unknown, and the pass carries on. A write error is recorded and the pass carries on. At the end the step is failed, with the number of chunks that had read or write errors and the first offset of each, and the device fails. A dying disk still gets every block zeroed that can be reached. After 64 failed chunks in a row the pass stops (the device has probably gone away) and the receipt says how many bytes were left.

Why not write zeros over everything

On thin storage (a qcow2 image, an LVM thin volume, a Ceph or SAN volume at a provider) the approaches end differently:

  • Zeros everywhere allocates every block. In the lab, a 10 MiB qcow2 overlay of a 3 GiB disk grew to the full 3 GiB.
  • Discard, then zeros is worse. The discard frees the blocks that held the data, and the zeros land on newly allocated blocks. The old contents stay in the storage's free space, never overwritten.
  • Normalize writes only where data was. A block that held data is already allocated, so the zeros go over it where it sits. A block that reads zero is unallocated or already zero: nothing in it to destroy, and writing it would only allocate space.

In the lab, a 512 MiB thin raw target held 536,875,008 allocated bytes before the wipe and exactly 536,875,008 after: every block of data was overwritten where it was, and nothing new was allocated.

Overlays and backing files

A qcow2 overlay, or any copy-on-write layer over a base image, is a snapshot by another name. Zeros written over data that lives in the base cannot go into the base, so the overlay stores them itself and grows by that much. The old data stays in the base file. In the lab, the root disk's overlay went from 10.4 MiB to 1.17 GiB: normalize wrote 1,258,225,664 bytes, all of them over data that came from the base image. (The old wipe grew the same overlay to the full 3 GiB.) The guest sees zeros; the base image still holds the original. Delete or wipe the base image at the provider, exactly as you would a snapshot.

What normalize does not reach

  • SSD flash cells. The controller sends each write to a fresh cell, so no write reaches a worn or retired cell. The hardware erase covers those.
  • Snapshots and copies on the storage side. Delete them at the provider.

Speed

Empty space is fast: reading is the cost, about 12 ms per 16 MiB chunk on top of the disk's read time. Space full of data is slower, because each 64 KiB piece is checked separately, roughly 1 ms each. A completely full 1 TB disk adds a few hours of CPU time to the read. Mostly empty disks, the usual case for a server, are dominated by read speed.

--passes N is accepted for old command lines and ignored with a warning: a second normalize would find every block zero and write nothing.

Reclaim

With --reclaim, after every target has verified, the wipe discards each target (blkdiscard) to give space back to thin storage and send a TRIM to an SSD, then verifies every target again in the same mode as the first verify. On an in-place wipe, the disk's read-only fence is lifted for the discard and set again before the second verify.

  • Reclaim runs only after normalize and the first verify, so the blocks it frees already hold zeros.
  • If any target failed its first verify, reclaim is skipped and recorded as skipped.
  • A discard that is unsupported or errors is recorded, but does not by itself fail the device. The second verify decides.
  • Some SSDs do not promise that a discarded block reads back zero. If the second verify finds data, the receipt says the device does not read zeros after a discard, and that device fails, because you asked for the reclaimed state to be proven.

Without --reclaim, nothing is ever discarded.

In the lab, --reclaim on an in-place wipe shrank the root overlay from 11,083,776 bytes to 598,016, and a thin raw target's allocation went from 536,875,008 bytes to 0, with the second verify passing on both.

Per-file proof

Before quiescing, the wipe maps sensitive files to the places they sit on the target disks:

  • a built-in list: /etc/shadow, the SSH host keys, /etc/ssl/private, /etc/letsencrypt, /etc/wireguard, /etc/krb5.keytab, /root/.ssh and each user's .ssh, /var/lib/tailscale;
  • plus every path in the inventory's meta/paths.txt when you pass --out DIR (or set DECOM_OUTDIR).

Directories expand to the files under them. Only filesystems on a target disk are walked: a listed path on another filesystem is skipped, and the walk stops at mounts that leave the targets, such as /proc, /sys and tmpfs. Up to 20,000 files on the targets are mapped; the receipt says if that cap was reached. Each file's physical extents come from the filesystem (filefrag), converted to byte offsets on the disk using the partition's start.

After the verify, exactly those extents are read again with direct I/O and must be zero. The receipt gives the number of files, extents and bytes re-read, and names any file whose extents were not zero. It never shows a file's contents.

Some files cannot be mapped this way: data stored inline in the inode, delayed-allocation, encrypted or compressed extents, and files on LVM, RAID, multi-device btrfs or overlay filesystems, where an extent is not a simple offset on one disk. The receipt counts them as unmapped with the reason. They are still covered by the full verify; they just lack their own line.

The receipt

Every step records one outcome:

OutcomeMeaning
okThe step ran and did what it says.
unsupportedThe device or system has no way to do this (a virtual disk has no hardware erase; a tool is not installed).
frozenAn ATA drive's security is frozen by the firmware, so secure erase cannot be sent.
failedThe step ran and did not succeed. Most failures make the device's verdict FAIL.
skippedNot attempted, with the reason (for example, reclaim after a failed verify).

A real receipt from the lab, verbatim, for an in-place wipe of a VM's running 3 GiB root disk:

===================== DECOM WIPE RECEIPT =====================
tool:     decom.sh 1.1.0
host:     decomlab
started:  2026-10-30T17:57:56Z
finished: 2026-10-30T17:58:26Z
run:      in-place (re-exec from tmpfs /run/decom.20261030T175756Z)
normalize: conditional zero pass: every block read (direct I/O, 16 MiB chunks); zeros
          written only to 64 KiB blocks not already zero, in place; no discard before it
verify:   full read-back of every byte
reclaim:  not requested; nothing was discarded
files:    8 files on the targets mapped to 8 extents (built-in list; 0 empty; 0 not on a target)
caveat:   in-place: the running kernel still holds the target filesystems (read-only,
          on devices set read-only). This receipt covers the disk as of the re-check
          10s after the verify; a write the kernel makes after that is not covered.

device /dev/vda
  model=?  serial=decomroot  size=3221225472 bytes
  signatures               ok           10 offsets noted (signatures and partition starts)
  hw-erase                 unsupported  no hardware erase path for vda (virtual device?)
  wipefs                   ok           /dev/vda: 2 bytes were erased at offset 0x000001fe (PMBR): 55 aa
  normalize                ok           conditional zero pass: 3,221,225,472 of 3,221,225,472 bytes read; 1,258,225,664 bytes written where a block was not already zero (19199 blocks of 64 KiB in 600 writes, in 93 of 192 16 MiB chunks)
  fence                    ok           
  verify                   ok           full read-back: 3221225472 of 3221225472 bytes read, all zero
  file-extents             ok           8 files, 8 extents, 32,768 bytes re-read, all zero
  recheck                  ok           5 signature blocks still zero
  normalized to zero: 3,221,225,472 bytes read, 1,258,225,664 bytes written where a block was not already zero; every block verified zero.
  verify:  full: 3221225472 of 3221225472 bytes read, all zero
  files:   8 files, 8 extents, 32,768 bytes re-read, all zero
  device verdict: PASS

host
  file-map                 ok           8 files on the targets mapped to 8 extents (built-in list; 0 empty; 0 not on a target)
  swapoff                  ok           
  swap-check               ok           no active swap on the targets
  journald-relinquish      ok           
  stop:unattended-upgrades.service ok           
  sync                     ok           
  remount-ro               ok           read-only: /boot/efi /
  fence:vda1               ok           
  fence:vda15              ok           
  holders                  ok           no md/dm/bcache device is built on the targets

VERDICT: PASS - the targeted devices read back as zero, every byte; no filesystem on them remains (in-place: as of the re-check 10s after the verify; the running kernel can still write later)
==============================================================

The receipt goes to the console (or the serial console), the log and RECEIPT.txt in the tmpfs, and its SHA-256 to the console. The tmpfs is gone when the machine powers off, so copy the receipt off first.

Getting the receipt off the machine

After an in-place wipe, new SSH logins usually fail. sshd starts a fresh copy of itself from the disk for every connection, and the disk is now zeros. In the lab, a new login after the wipe ended with Connection closed. Without help, the receipt is then only on the console, or in an SSH session that was already open. Two options fix that, and you can use both.

--serve ADDR:PORT

The wipe serves the receipt over plain HTTP from the tmpfs, with busybox httpd:

# decom.sh wipe --target /dev/vda --serve 10.8.0.5:8099
[decom] RECEIPT URL: http://10.8.0.5:8099/6ac9bef95959a4178fa0c4db1a61554d/
...
Type the device names to wipe, separated by spaces ("vda"): vda
  • ADDR is an IP address on this machine (IPv6 in brackets: [2001:db8::1]:8099) or a name that resolves to one. A name is resolved before the wipe, while name lookup still works. An address that is not on this machine, a missing or bad port, or a missing busybox httpd is refused before the confirmation.
  • Everything lives under one random path, a 32-character token from /dev/urandom: decom.log (live, so you can watch the wipe), and RECEIPT.txt, RECEIPT.txt.sha256 and VERDICT once they are written. Nothing else is reachable. / and /<token>/ return an empty page, not a listing; any other path returns 404; there is no CGI.
  • The URL is printed before the confirmation (your terminal and the console), in the receipt, and again at the end, next to the receipt's SHA-256.
  • The server runs as the systemd unit decom-serve (without systemd, under setsid and busybox timeout), only from the tmpfs, so it outlives the wipe. It stops at power-off, or after --serve-for (default 24 hours).
  • The receipt records serve ok with the URL, or serve failed with the reason. If nftables, iptables or ufw has input rules that may block the port, the wipe warns and the receipt says so. It never changes your firewall.
$ curl -O http://10.8.0.5:8099/6ac9bef95959a4178fa0c4db1a61554d/RECEIPT.txt
$ curl -O http://10.8.0.5:8099/6ac9bef95959a4178fa0c4db1a61554d/RECEIPT.txt.sha256
$ sha256sum -c RECEIPT.txt.sha256
RECEIPT.txt: OK

The pages show the host name, disk models and serial numbers, and file paths. They hold no secrets, but they travel as plain HTTP. Use a private, mesh or VPN address, and compare your copy with the SHA-256 printed on the console, not only with the .sha256 file served next to it.

--send-receipt URL

After the receipt is written, the wipe uploads it once: an HTTP POST with the receipt as the raw body and Content-Type: text/plain.

# decom.sh wipe --target /dev/sda --send-receipt https://ntfy.example.net/decom \
    --send-receipt-header "Authorization: Bearer $TOKEN"
  • http and https only, checked before the confirmation. Credentials inside the URL are refused; pass them as headers with --send-receipt-header 'Name: value', which you can repeat. Header values and the URL's query string are never printed or logged, only header names.
  • curl, its libraries and the CA bundle are copied into the tmpfs, and the URL's host is resolved before the wipe and pinned with curl --resolve, because name lookup and the certificate store are gone once the disk is zeros.
  • Connect timeout 10 seconds, 60 seconds per try, up to 3 retries.
  • The body is the receipt as written. The outcome, send-receipt ok with the HTTP status or send-receipt failed with the reason, is then appended to the local RECEIPT.txt as one line after the closing line. So the local copy is exactly the uploaded body plus that line, and the receipt's sent to: line says so.
  • A failed send never changes the verdict.

Options

OptionPhaseWhat it does
--out DIRallInventory directory (default /tmp/decom.<time>). For wipe, its meta/paths.txt feeds the per-file proof.
--target DISKwipeA whole disk to wipe. Repeatable and required.
--reclaimwipeDiscard every target after it verifies, then verify again.
--sample NwipeVerify the first block, the last block and N random 1 MiB blocks instead of every byte. Proves only those blocks.
--no-hw-erasewipeSkip ATA, NVMe and SCSI hardware erase.
--hw-timeout SwipeLongest wait for an NVMe sanitize (default 7200 seconds).
--allow-partialwipeAllow disks on the machine to stay untargeted. They are listed. Does not cover NVMe namespaces a sanitize would reach.
--allow-loopwipeAccept loop devices as targets. For testing.
--dry-runwipePrint every command the wipe would run. Touches nothing.
--yeswipeSkip the typed confirmation. Required when stdin is not a terminal.
--serve ADDR:PORTwipeServe the receipt, its SHA-256, the verdict and the live log over plain HTTP from the tmpfs at http://ADDR:PORT/<random token>/. ADDR must be on this machine. See getting the receipt off the machine.
--serve-for DwipeHow long --serve runs: N, Ns, Nm or Nh (default 24h). It also stops at power-off.
--send-receipt URLwipePOST the receipt once to an http or https URL after it is written. A failed send does not change the verdict.
--send-receipt-header 'Name: value'wipeAn extra header for --send-receipt, such as an auth token. Repeatable. Only the name is ever printed.
--passes NwipeIgnored, with a warning. Kept so old command lines still run.
-h, --helpallShow the built-in help.

Commands: inventory, checklist, wipe, selftest, and all (inventory plus checklist, then it stops).

Exit status

  • 0: every target passed.
  • 1: any failure, including a refused target, a verify that found non-zero data, or a verify that could not read every byte it was asked to read.
  • 2: the terminal following a detached wipe lost track of it before a verdict appeared. The wipe may still be running; check systemctl status decom-wipe and the receipt.

What it cannot prove

A tool that tells me what it CAN'T do? Is this... is this what trust feels like?
AYour auditorprocessing

A full verify proves:

  • every byte of each targeted disk, as this machine sees it, reads back as zero;
  • no filesystem or partition table remains on the targets;
  • the extents of each mapped sensitive file read zero;
  • every block that held data was overwritten in place, and blocks that read zero were not written.

Nothing inside the machine can prove:

  • that provider snapshots and backups are gone;
  • that the storage layer kept no copy of old blocks;
  • that SSD cells the controller no longer exposes are clean (the hardware erase is for that);
  • anything about sectors hidden by an ATA device configuration overlay (a host protected area is checked, and fails the run);
  • for an in-place wipe of the running root disk, that the kernel writes nothing after the final re-check.

Everything on the second list is why the rotation checklist exists. Rotate every credential that lived on the host, so that a block recovered from anywhere is worthless.

Rescue mode or in place

Run the wipe from the provider's rescue system whenever there is one. The targets are then not the running system, nothing is mounted from them, and every step works as described.

An in-place wipe of the running root disk works and is tested, but the kernel still holds the root filesystem. The wipe remounts it read-only, sets its partitions and then the whole disk read-only, and re-reads the signature offsets 10 seconds after the verify. In the lab, the kernel logged filesystem errors after the receipt, and the disk stayed all zero.

That fence exists because of something the lab caught: before it, an in-place wipe passed its verify, and then ext4's error handling rewrote the filesystem's superblock onto the zeroed disk. Remounting read-only does not stop that write; a read-only block device does. Kernels 5.10 and older may not honor a read-only device for that write, so on those only the re-check stands between the verify and a late write.

selftest and dry runs

decom.sh selftest copies the wipe's tools into a temporary directory and runs the same self-test the wipe runs before touching a disk. It needs no root and touches no disk. Run it on a new image before you need it.

decom.sh wipe --target ... --dry-run runs the target checks and prints every command the wipe would run, without the confirmation and without writing anything.

How it is tested

The repository's lab/run-lab.sh runs each scenario in a throwaway KVM guest (Debian 12, kernel 6.1, QEMU 8.2.2) and records the operator's SSH session and the guest's serial console. After each wipe the VM is stopped and the host checks the target's disk image without trusting the script: converted to raw and compared with zeros, searched for a marker string written before the wipe, and the image file's own bytes searched for it as well.

ScenarioWhat it shows
S1A second disk wiped from rescue-style: 519,707,681 non-zero bytes to 0.
S2Partition, untargeted disk, LVM, loop and duplicate targets refused; the disk's hash unchanged.
S3rIn place with --reclaim after an inventory: 729 files mapped (none off the targets), all re-read zero; inventory directory 700, tarball 600; overlay 11,083,776 bytes to 598,016.
S3, S3bThe running root disk wiped in place: 905,611,732 non-zero bytes to 0, and still zero after the kernel's late writes.
S3xThe probe that found the superblock rewrite: a read-only remount alone lets it through, a read-only device stops it.
S4Read errors injected on purpose: the run reports FAIL and exits 1, and the rest of the disk is still zeroed (0 non-zero bytes left on the host-side image).
S5An emulated NVMe drive: sanitize recorded as unsupported, format used, disk all zero.
S6Sampled verify on a 5 TiB disk reaches offsets past 4 TiB.
S7The tools copied into RAM work, and none of the copies overwrote another.
S8In place with --serve: decom.log fetched 15 times from the host while the wipe ran; afterwards RECEIPT.txt matched both its .sha256 file and the SHA-256 on the console, and said VERDICT: PASS. / and /<token>/ listed nothing, a wrong token got 404. A new SSH login after the wipe failed (Connection closed); the HTTP fetch still worked.
S9, S9d--send-receipt with an Authorization header: the header arrived, the body was the receipt minus the appended send-receipt ok HTTP 200 line, and the token appeared nowhere in the console, log or receipt. With the receiver down: send-receipt failed after 4 tries, verdict still PASS.
S10An address not on the machine, bad ports, a missing port, an unresolvable name, and ftp://, file:// or credential URLs: all refused before the confirmation, exit 1, disk hash unchanged.

Not covered by the lab: waiting for a real NVMe sanitize (QEMU's emulated drive has none), ATA and SAS secure erase, and the host protected area check. Those need real drives.

Questions

Why zeros and not random data?

Because zeros can be verified. A random pass leaves nothing to check the disk against, and on modern drives a single pass of anything is enough for the blocks you can address. The blocks you cannot address are the hardware erase's job.

Why not just delete the instance?

Deleting an instance tells the provider you are done with it. It does not say when or how the blocks are reused, and it does nothing about snapshots. Wipe first, then delete, then rotate.

Can I wipe a single partition?

No. Partition tables, RAID metadata, other volumes and another disk's EFI partition all leave data behind. Whole disks only.

Why do I have to type the device names?

Because "yes" is typed out of habit, and sda sdb is typed while looking at the serial numbers on the screen.