# Four Debian 13 Boxes, One Brief: 1,923 Packages on Metal, 328 in the Cloud

> Source: <https://dev.to/aws-builders/four-debian-13-boxes-one-brief-1923-packages-on-metal-328-in-the-cloud-and-the-backup-gpt-2jn9>
> Published: 2026-09-09 20:59:14+00:00

This article covers one afternoon of running the same Debian 13 on four machines — one laptop and three clouds — and asking each of them the same question about its own hardware.

**A cloud image is not a small Debian. It is a different bet about what you will need.** The laptop install carries 1,923 packages. The three cloud images carry 328, 337 and 350. That is not a rounding difference, and the interesting part is not the count — it is that all four are booting from NVMe, and only two of them ship anything that can read an NVMe health log.

What that creates is a gap with two halves. The hardware is present and the kernel can see it. The default toolset cannot. And because nothing fails, nothing tells you:

`nvme-cli`` dmidecode` and `Vulnerability Retbleed: Vulnerable`` GPT:16777215 != 41943039`, still wrong at the boot I measured. GCE emits the identical warning and That last pair is the whole article. Same kernel, same message, opposite correct answers.

[https://github.com/xbill9/local-bench](https://github.com/xbill9/local-bench)

Four hosts, all Debian 13 (trixie), all measured on 2026-09-09:

|  | Image | Shape | CPU | Kernel | 
|---|---|---|---|---|
| **local** | installed, bare metal | Lenovo 83B1 | i7-1360P, Raptor Lake, 16 threads | `7.1.8+deb13-amd64` (backports) | 
| **AWS** | `ami-0871da4641e8b4413` | c7i.large | Xeon 8488C, Sapphire Rapids, 2 vCPU | `6.12.107+deb13-cloud-amd64` | 
| **GCE** | `debian-13-trixie-v20260908` | c4-standard-2 | Xeon 8581C, Emerald Rapids, 2 vCPU | `6.12.107+deb13-cloud-amd64` | 
| **Azure** | `Debian:debian-13:13-gen2:0.20260831.2587` | Standard_D2s_v7 | Xeon 6973P-C, Granite Rapids, 2 vCPU | `6.12.107+deb13-cloud-amd64` | 

One detail decides how to read everything below. **The AWS and Azure images are the same upstream Debian build** — `2587`, dated 2026-08-31 — running the same kernel. GCE builds its own, a week newer. So where AWS and Azure differ, the distro is not the variable. The platform is.

`ssh`, and a key you are willing to put on a throwaway host
Steps 0 through 2 are reads and launches. Step 3 installs software, and **Step 3 is the point of no return for the baseline** — see Step 1 for why that ordering is not optional.

An image family is a moving target, and "Debian 13" is not a measurement. Resolve it to an ID before you launch anything, and write the ID down.

```
# AWS — the official Debian account is 136693071363
aws ec2 describe-images --owners 136693071363 \
  --filters "Name=name,Values=debian-13-amd64-*" "Name=state,Values=available" \
  --query 'sort_by(Images,&CreationDate)[-1].{Name:Name,ID:ImageId,Date:CreationDate}'

# GCE — resolve the family to the image it currently points at
gcloud compute images describe-from-family debian-13 \
  --project debian-cloud --format="value(name)"

# Azure — the stable offer is debian-13; debian-13-daily is a different thing
az vm image list --publisher Debian --offer debian-13 --sku 13-gen2 --all \
  --query "[?offer=='debian-13'] | sort_by(@,&version)[-1].urn" -o tsv
```

That third one has a trap in it. Asking Azure for `--offer debian-13` with `--all` will happily return `debian-13-daily` URNs alongside the stable ones, because the filter matches loosely. The `[?offer=='debian-13']` clause is doing real work — without it you will pin a daily build and not notice.

This is also where the AWS/Azure coincidence shows up: both resolved to build `2587`, dated 2026-08-31. That is not a fluke, it is the same upstream Debian cloud image, and it is the single most useful fact for interpreting the rest.

**This step exists because I got it wrong the first time.**

The first EC2 instance I launched had a `user-data` script that installed about twenty packages before I ever logged in. Then I scanned it and wrote down what was present. Every one of those findings was worthless: I was measuring my own cloud-init, not Debian's image. That instance had to be discarded and relaunched with no `user-data` at all.

A default-install analysis has exactly one chance to be correct, and it is before the first `apt-get install`.

The scanner has to run on a machine where nothing is installed, which means it can only use what a minimal cloud image actually ships — bash, coreutils, `/proc`, `/sys`, `dpkg`. No `jq`. No `lspci`, because `pciutils` might not be there. Absence is the data, so nothing may fail:

```
# hardware, without needing dmidecode
for f in sys_vendor product_name bios_version bios_date; do
  [ -r "/sys/class/dmi/id/$f" ] && printf '%-16s %s\n' "$f" "$(cat /sys/class/dmi/id/$f)"
done

# what the image actually ships, by dpkg, not by PATH
for p in pciutils usbutils dmidecode nvme-cli smartmontools lm-sensors \
         ethtool intel-microcode fwupd build-essential gcc git unzip; do
  s=$(dpkg-query -f '${db:Status-Status}' -W "$p" 2>/dev/null)
  [ "$s" = installed ] && echo "PRESENT $p" || echo "absent  $p"
done
```

**Check packages with `dpkg`, not with `command -v`.** This is the second thing I got wrong, and it is worth more than the first. My initial pass over the laptop reported `dmidecode`, `nvme-cli`, `smartmontools`, `hdparm`, `ethtool`, `powertop` and `turbostat` as missing. All seven were installed. My login `PATH` contained no `/usr/sbin`, no `/sbin`, no `/usr/local/sbin`, and a bare `command -v` cannot see a binary it has no path to. Seven false negatives from one unstated assumption, on the machine I know best.

The full scanner is `evidence/baseline-scan.sh` in the repo. Run it, keep the output, then install things.

No `user-data`, no startup script, no custom image. Lock SSH to your own address rather than the world — every one of these CLIs will happily open port 22 to `0.0.0.0/0` if you let it.

```
MYIP=$(curl -s https://checkip.amazonaws.com)/32

# AWS
aws ec2 run-instances --image-id <ami> --instance-type c7i.large \
  --key-name <key> --security-group-ids <sg> --subnet-id <subnet> \
  --associate-public-ip-address --metadata-options 'HttpTokens=required'

# GCE
gcloud compute instances create baseline --zone=us-east1-b \
  --machine-type=c4-standard-2 --image-family=debian-13 --image-project=debian-cloud

# Azure — --nsg-rule SSH opens 22 to the internet; narrow it afterwards
az vm create -g <rg> -n baseline --image <urn> --size Standard_D2s_v7 \
  --admin-username azureuser --ssh-key-values ~/.ssh/id_ed25519.pub --nsg-rule SSH
az network nsg rule update -g <rg> --nsg-name baselineNSG --name default-allow-ssh \
  --source-address-prefixes "$MYIP"
```

Azure will also refuse a size that is not offered to your subscription in that region, and the CLI reports it badly — mine crashed inside its own error handler with `RuntimeError: The content for this response was already consumed`, having created nothing. The real cause was that `Standard_D2s_v6` does not exist for that subscription in `eastus`. Ask before you launch:

```
az vm list-skus -l eastus --size Standard_D2s_v7 \
  --query "[].{Name:name,Restrictions:restrictions[].reasonCode}" -o json
```

An empty array means the SKU is not available to you. That is the answer the failed deployment was trying to give.

```
curl -fsSL https://claude.ai/install.sh | bash
```

On all three clouds, on images with **no `node`, no `npm`, no `unzip` and no compiler**:

|  | AWS | GCE | Azure | 
|---|---|---|---|
| Elapsed | 10 s | **8 s** | 12 s | 
| Version | 2.1.266 | 2.1.266 | 2.1.266 | 
| Installed size | 206 MB | 206 MB | 206 MB | 

`ldd` on the binary returns `linux-vdso`, `librt`, `libc`, `libpthread`, `libdl`, `ld-linux` — glibc 2.41 and nothing else. The prerequisites are `curl` and a writable `$HOME`, and every one of these images has both.

All three printed the same warning: `~/.local/bin is not in your PATH`. Debian's `~/.profile` adds that directory only if it exists **at login**, and the installer creates it mid-session. Three for three, and it resolves itself on the next login.

This is the part that changes the result. Rather than SSH-ing in and running commands from outside, install Claude Code on each box and have it examine the machine it is running on:

```
claude -p "$(cat brief.txt)" --allowedTools Bash
```

`--allowedTools Bash` pre-approves the one tool the job needs. The blunter `--permission-mode bypassPermissions` disables the permission system wholesale and is the wrong instrument here.

The brief asks for seven things in order — identity, drivers, firmware, kernel errors, failed units, system tools, gaps — and ends by asking for a verdict of at most eight lines. The full text is `evidence/deep-analysis-prompt.txt`.

One clause in it earned its place:

IMPORTANT: check with an explicit PATH that includes /usr/sbin, /sbin and /usr/local/sbin, and cross-check against dpkg — a tool in /usr/sbin is NOT missing just because a bare `command -v` under a login PATH cannot see it. This exact mistake has already been made once in this project.

Without it, four machines would have independently reproduced my Step 1 error. With it, the Azure instance flagged `ethtool` as "the tool a bare login-PATH `command -v` missed earlier in this project" — it had read the warning and applied it.

**One caveat, and it is mine.** The brief says "use your Bash tool" and never says read-only. The laptop's instance ran `update-initramfs -u` during its analysis, rebuilt the boot image, and said so at the top of its report. The result was benign — it incidentally shipped the `rtl8156b-2.fw` blob that had been missing from that initrd — and the three cloud instances wrote nothing. But that was the brief's luck, not its design. **If you reuse this, say read-only.**

`dpkg`-verified, on pristine images, before anything was installed:

| Package | local | AWS | GCE | Azure | 
|---|---|---|---|---|
| `pciutils` | YES | YES | YES | YES | 
| `ethtool` | YES | YES | YES | YES | 
| `curl` /`wget` /`python3` | YES | YES | YES | YES | 
| `dmidecode` | YES | — | **YES** | — | 
| `nvme-cli` | YES | — | **YES** | — | 
| `smartmontools` | YES | — | — | — | 
| `lm-sensors` | YES | — | — | — | 
| `lshw` /`usbutils` | YES | — | — | — | 
| `linux-cpupower` /`powertop` | YES | — | — | — | 
| `intel-microcode` /`fwupd` | YES | — | — | — | 
| `build-essential` /`gcc` /`git` | YES | — | — | — | 
| `numactl` /`hwloc` /`gawk` | — | — | — | — | 
| **Total installed** | **1,923** | **337** | **350** | **328** | 

Five packages are universal. Everything else is a choice, and **GCE makes the most generous one** — it is the only image of the three that can inspect its own disk or decode its own SMBIOS out of the box.

The gap has a second half that no package closes. Some facilities are absent because the hypervisor does not expose them, and installing the tool achieves nothing:

|  | local | AWS | GCE | Azure | 
|---|---|---|---|---|
| `/sys/class/hwmon` | present | **absent** | **absent** | **absent** | 
| `thermal_zone*` | 10 | none | none | none | 
| `/sys/class/powercap` (RAPL) | present | **empty, modules loaded** | absent | absent | 
| `thermal_throttle` counters | present | absent | absent | absent | 
| EDAC | present | absent | absent | absent | 
| TPM | present | none | **vTPM present** | disabled by config | 
| microcode revision | `0x6134` | `0x2b000661` | **`0xffffffff`** | **` 0xffffffff`** | 

Two of these repay a second look.

**AWS's RAPL is not simply missing.** The instance found `intel_rapl_msr` and `intel_rapl_common` loaded and a `psys` PMU domain registered — KVM is exposing the MSRs — but `/sys/class/powercap` is empty because the `msr` module is not loaded and no reader is installed. On AWS, power is recoverable with `linux-cpupower` and a `modprobe`. On GCE and Azure, RAPL is not virtualised at all, and no tooling will bring it back. Those two states look identical from a distance and are not the same problem.

**GCE and Azure mask the microcode revision** to `0xffffffff`. AWS reports a real one. If your compliance story involves auditing microcode level in-guest, it works on one of these three.

AWS and GCE both emit this during boot:

```
GPT:16777215 != 41943039
GPT: Use GNU Parted to correct GPT errors.
```

16,777,215 sectors is 8 GiB. 41,943,039 is 20 GiB. Both disks were created at 8 GiB and requested at 20; the primary GPT was updated and the backup header at the end of the disk was not moved to match. The kernel falls back to the primary and boots.

On AWS it is **still wrong**. The instance ranked it as action item #1 and gave the fix:

```
sudo sgdisk -e /dev/nvme0n1
```

On GCE the same warning appears at t=1.95s and t=2.23s and is then **corrected inside the same boot**, because Google ships `gce-disk-expand` in the image and AWS ships nothing equivalent. Azure never emits it at all.

Same kernel. Same message. On one machine it is a defect you must fix; on another it is a transient you must ignore. Nothing in the message says which, and no amount of reading the message from outside the machine would tell you — you need the boot timeline, the package list and the agent's behaviour together.

**Azure is exposed to Retbleed.** `lscpu` reports `Vulnerability Retbleed: Vulnerable`, tied to the microcode Hyper-V presents to the guest. AWS and GCE report no such thing. There is no in-guest fix; it depends on Azure host-side updates. Azure also has Secure Boot **and** vTPM available and **both disabled** on a default VM.

**GCE fails a boot step every time.** The `hwclock` binary is absent, so `google_guest_agent`'s clock-skew-setup step fails on every boot. Impact is near zero — `kvm-clock` is authoritative — but it is a permanent error in a log nobody reads.

**Azure has zero unbound PCI devices.** It presents three PCI devices, all bound, plus thirteen VMBus devices, all bound. AWS presents about 45 with four unbound legacy stubs (440FX, PIIX3 ISA, PIIX4 ACPI, an emulated VGA); GCE presents 15 with three. Hyper-V Gen2 emulates no legacy chipset at all, and AWS still boots **legacy BIOS** with an unused EFI System Partition mounted at `/boot/efi`.

Worth stating plainly, because it is the larger part of the result. On all four machines: **kernel taint 0**, **zero failed systemd units**, `is-system-running: running`, and every PCI device with a real function had its driver bound. On all three clouds: **zero firmware-load attempts and zero firmware packages** — which is consistent rather than broken, because `ena`, `gve`, `mana`, `nvme` and `virtio` request no blobs.

The shared noise is shared exactly: `systemd-ssh-generator` fails its AF_VSOCK probe every boot on all three clouds, none of which expose a working vsock CID.

```
# 0. pin the image to an ID, never a family
aws ec2 describe-images --owners 136693071363 --filters "Name=name,Values=debian-13-amd64-*" \
  --query 'sort_by(Images,&CreationDate)[-1].ImageId'
gcloud compute images describe-from-family debian-13 --project debian-cloud --format="value(name)"
az vm image list --publisher Debian --offer debian-13 --sku 13-gen2 --all \
  --query "[?offer=='debian-13'] | sort_by(@,&version)[-1].urn" -o tsv

# 1. confirm the SKU is actually offered to you before launching
az vm list-skus -l eastus --size Standard_D2s_v7 --query "[].name" -o tsv

# 2. scan BEFORE installing anything -- packages by dpkg, never by command -v
dpkg-query -f '${db:Status-Status}\n' -W nvme-cli dmidecode smartmontools
dpkg-query -f '.\n' -W | wc -l

# 3. tools by an sbin-inclusive PATH, or you will invent false negatives
PATH=/usr/local/sbin:/usr/local/bin:/usr/sbin:/usr/bin:/sbin:/bin command -v nvme smartctl turbostat

# 4. is the facility absent, or just unreadable? absent = no package will help
ls /sys/class/hwmon /sys/class/powercap /sys/devices/system/edac 2>&1
lsmod | grep -c rapl        # loaded modules + empty powercap = recoverable
grep -m1 microcode /proc/cpuinfo   # 0xffffffff = hypervisor is masking it

# 5. the backup GPT header, on any volume you have ever grown
sudo dmesg | grep -i 'GPT:'
sudo sgdisk -e /dev/nvme0n1        # only if the mismatch persists after boot

# 6. install Claude Code and let the machine read itself
curl -fsSL https://claude.ai/install.sh | bash
claude -p "$(cat brief.txt)" --allowedTools Bash
```

The goal was to find out what a stock Debian 13 cloud image knows about the hardware underneath it. The answer is: less than the hardware is willing to say, and the shortfall is different on every platform, in ways that a package list alone does not predict.

The key to getting a usable answer was giving each machine the same brief and letting it read its own boot log, because the two most interesting findings — a broken partition table on one cloud and a self-correcting one on another — are the same kernel message, and only the machine can tell you which one it is looking at.

The method's own two mistakes are in the article on purpose. Both were unstated assumptions, both produced confident wrong answers, and one of them was on the machine I know best.

**Repo:** [github.com/xbill9/local-bench](https://github.com/xbill9/local-bench) — `docs/debian-across-clouds/` carries the four self-analyses, the pristine baseline scans, the brief, and the benchmark and pricing data. Every evidence file ends with a "NOT measured" section listing which claims are inferences rather than measurements.
