EST. 2026 — Notes from real server work SYSADMIN · SECURITY · AI · DIRECTADMIN Issue 01
THE SYSADMIN ALMANAC
Home / Linux / Using Claude to Upgrade Proxmox 8 to 9 and Rebuild Windows VMs
Linux

Using Claude to Upgrade Proxmox 8 to 9 and Rebuild Windows VMs

by Ton-Or ·20 Jun 2026·13 min read
Using Claude to Upgrade Proxmox 8 to 9 and Rebuild Windows VMs

A real case where I never SSHed into a Proxmox host myself. I worked entirely through VS Code + Claude, from upgrading PVE 8 to 9, forming the cluster, setting up ZFS, and building a Windows golden image to cloning VDI machines across the cluster. This article lays out the commands and scripts Claude generated during the actual work so they can be read and run again.

I needed to upgrade my entire Proxmox cluster from 8 to 9, then delete and rebuild all Windows VMs for VDI, but the first thing I want to make clear is that I never SSHed into a Proxmox host myself, not even once. I worked entirely through VS Code + Claude: Claude wrote the scripts and sent them to the nodes, while I directed the work and gave instructions one step at a time.

So in this article, I have taken the commands and scripts Claude generated during the actual work and laid them out step by step. You can read them yourself, or have an AI read this article and run the commands on your own machines, because the workflow is repeatable. I have already written separately about the principles of using AI to manage Linux servers safely without accidentally breaking things in Managing Linux Servers Safely with AI over SSH. This is a full account of an actual case.

Every script comes from the actual files on the nodes, with only passwords removed (the cluster is entirely on an internal network, so I have kept the actual machine numbers and IPs), and passed a syntax check + dry-run to review the logic before execution.

How Claude sends commands to the nodes

Claude stores each script at /root/*.sh on the node and sends it through PuTTY's plink/pscp, rather than typing long inline commands directly through PowerShell, because CRLF and quoting regularly break things — write a file, upload it with pscp, then always run sed -i 's/\r//g' before bash (SSH uses the standard port 22). This is what lets an AI read the article and follow it directly: files and a fixed sequence of steps.

1. The Layout Before Starting

My cluster has 3 nodes: po001 (172.22.22.51, zpool ss1t01), po002 (172.22.22.52, the controller, zpool ss1t02), and po003 (172.22.22.53, zpool ss500g03). Everything is on an internal network (172.22.22.x is private), with the web interface on :8006 as usual.

The key is systematic naming so things do not get confusing when cloning lots of machines. These are the rules I follow:

  • VMID = VLAN tag = slot for that machine · name clones win-c<VMID> · name masters win-master-poXXX
  • Master per node = 900<NN> (9001/9002/9003) · ZFS-template per node = 950<NN> (9501/9502/9503)
  • mgmt IPs follow VMID = 10.77.<เลข node>.<VMID%100> · the host itself is .254
  • net0 = production bridge (tag = VMID, a separate VLAN per machine) · net1 = mgmt bridge (RDP/SSH, no gateway)
Who directs whatItype plain-language instructions->VS Code + Claudewrites+sends scripts->SSH :22->nodeqm/pvecm/zpool
Redaction note

All root and Windows passwords in this article are replaced with <ROOT-PW> / <PW>. I have kept the IPs/hostnames because these are local machines on an internal network. Replace all of them with your own values before using this.

2. Upgrade PVE 8.4 -> 9 In Place (Keep All VMs)

PVE 8 to 9 is really Debian 12 (bookworm) to 13 (trixie), a major upgrade. I worked one node at a time, starting with a pilot on the node with the fewest VMs (po003) to limit the damage if something broke. There is no need to delete VMs; just shut them down before rebooting.

Do not let a network drop lock you out

During the upgrade, NICs may be renamed, leaving SSH inaccessible. Always have a console/IPMI/physical monitor available before starting, then back up the config + record the current NIC names for comparison. Otherwise, if SSH drops, your only option is to walk over to the machine.

# มี console สำรองก่อนเริ่ม (NIC อาจ rename = SSH เข้าไม่ได้)
cp -a /etc/network/interfaces /etc/pve /etc/ssh/sshd_config /etc/hostname /etc/hosts /root/bk-pre9/
ip -br link                 # จดชื่อ NIC ปัจจุบันไว้เทียบหลังอัป
pve8to9 --full              # checklist read-only -> แก้ทุก FAIL/WARN ให้หมดก่อน
pvesm set local --delete maxfiles   # FAIL จริงที่เจอ: storage มี maxfiles ต้องลบทิ้ง
qm shutdown <id>            # ปิด VM ทุกตัวบน node ก่อน reboot

The actual upgrade script I used switches the repos to trixie + no-subscription, disables the enterprise repos (pve + ceph), then runs a non-interactive dist-upgrade while keeping the existing config so it does not prompt for each file.

#!/bin/bash
# /root/repo-upgrade.sh
export DEBIAN_FRONTEND=noninteractive
export PATH=/usr/sbin:/usr/bin:/sbin:/bin:$PATH

# 1) ปิด enterprise repo (pve + ceph) -> Enabled: false
for f in /etc/apt/sources.list.d/*.sources; do
  grep -qi 'enterprise.proxmox.com' "$f" 2>/dev/null || continue
  grep -q '^Enabled:' "$f" && sed -i 's/^Enabled:.*/Enabled: false/' "$f" || echo 'Enabled: false' >> "$f"
done

# 2) เพิ่ม pve-no-subscription (trixie, deb822)
cat > /etc/apt/sources.list.d/pve-no-subscription.sources <<'EOF'
Types: deb
URIs: http://download.proxmox.com/debian/pve
Suites: trixie
Components: pve-no-subscription
Signed-By: /usr/share/keyrings/proxmox-archive-keyring.gpg
EOF

# 3) Debian repo เปลี่ยน bookworm -> trixie
sed -i 's/bookworm/trixie/g' /etc/apt/sources.list.d/debian.sources

# 4) update + dist-upgrade (เก็บ config เดิม = confold/confdef)
apt-get update
apt-get -y -o Dpkg::Options::=--force-confold -o Dpkg::Options::=--force-confdef dist-upgrade
apt-get -y autoremove

After dist-upgrade finishes, there are things you must not forget before rebooting.

update-grub                 # ไม่งั้น reboot กลับ kernel เก่า 6.8 (legacy GRUB ไม่ชี้ kernel ใหม่)
a2dismod php7.3 2>/dev/null; systemctl restart apache2   # php7.3.load ชี้ .so ที่หาย -> apache fail
systemctl reboot
# verify: pveversion=9.x · uname -r=7.0.x-pve · qm list ครบ · ip -br link (NIC ชื่อเดิม) · ssh ใช้ได้
Pitfalls I actually hit

Do not forget update-grub · Run pvesm set <store> --delete maxfiles first · If apache is installed, run a2dismod php7.3 · [email protected] fail is cosmetic and clears on its own · Windows ssh.exe hangs with OpenSSH 10; use git-bash ssh or plink · The host responds to ping about 30-40 seconds before sshd is ready; wait and retry before worrying.

3. Delete Old VMs Before Building a New Master/Fleet

Before setting up the new machines, I cleared out the old VMs. There are two scripts: the first deletes everything; the second deletes only the extras (keeping the master + machines in use).

#!/bin/bash
# /root/delete-all.sh — stop+destroy ทุก VM + ลบ ISO เก่า (เก็บ virtio + win11 iot)
export PATH=/usr/sbin:/usr/bin:/sbin:/bin:$PATH
ids=$(qm list 2>/dev/null | awk 'NR>1{print $1}')
for id in $ids; do
  qm stop "$id" --skiplock >/dev/null 2>&1
  qm destroy "$id" --purge --skiplock >/dev/null 2>&1   # --purge = ลบ disk + ออกจาก config
done
cd /var/lib/vz/template/iso && for f in *.iso; do
  case "$f" in virtio-win-latest.iso|win11-iot-ltsc-2024.iso) : ;; *) rm -f "$f";; esac
done
# /root/deletevms.sh — เก็บ VMID ต่ำสุดตาม KEEP, ลบตัวท้ายที่ stopped, ข้าม running + master 9xxx
NN=$((10#$(hostname|tail -c2)))
case $NN in 1) KEEP=7;; 2) KEEP=15;; 3) KEEP=15;; esac
base=$((NN*100))
for conf in /etc/pve/nodes/"$(hostname)"/qemu-server/*.conf; do
  id=$(basename "$conf" .conf); case "$id" in ''|*[!0-9]*) continue;; esac
  { [ "$id" -ge $((base+1)) ] && [ "$id" -le $((base+99)) ]; } || continue   # เว้น master 9xxx
  if [ $((id-base)) -gt "$KEEP" ]; then
    [ "$(qm status "$id"|awk '{print $2}')" = running ] && continue
    qm destroy "$id" --purge
  fi
done

4. Form the Cluster (pvecm)

Combine the 3 standalone nodes into a single cluster named pocluster (corosync runs on 172.22.22.x), using the console or SSH.

# --- บน po001 (node แรก สร้าง cluster) ---
pvecm create pocluster
pvecm status                 # ดู Quorate + node แรก

# --- บน po002 แล้ว po003 (join เข้า cluster) ---
pvecm add 172.22.22.51       # ใส่ root pw ของ po001; ต้อง SSH ถึง .51 ได้ก่อน
pvecm status                 # ต้องเห็น 3 node, Quorate: Yes

The actual result was Name: pocluster · Nodes: 3 · Quorate: Yes, and /etc/pve/nodes/{po001,po002,po003} appears on every machine because the config syncs automatically through corosync.

Precautions

corosync uses the 172.22.22.x network for the cluster link. Do not add nodes with conflicting VMIDs — I already separate VMID ranges by node (1xx/2xx/3xx), so there were no conflicts here. Once clustered, the nodes have SSH keys for access to one another, so BatchMode works immediately.

5. Add ZFS Storage per Node

Turn the Samsung SSD (/dev/sda) into a zpool (with a unique name per node), then register it as PVE storage. A safety abort stops the script if the disk is not a Samsung, to help prevent modifying the wrong disk.

# /root/zfsconv.sh
NN=$((10#$(hostname|tail -c2)))
case $NN in 1) POOL=ss1t01;; 2) POOL=ss1t02;; 3) POOL=ss500g03;; esac
DEV=/dev/sda
case "$(lsblk -dno MODEL $DEV)" in *Samsung*) ;; *) echo ABORT; exit 1;; esac   # กันผิดดิสก์
wipefs -a "${DEV}1" "$DEV"
zpool create -f -o ashift=12 -O compression=lz4 -O atime=off "$POOL" "$DEV"
pvesm add zfspool "$POOL" --pool "$POOL" --content images,rootdir --nodes "$(hostname)"
zpool list "$POOL"; pvesm status | grep "$POOL"
This wipes the entire disk

wipefs + zpool create delete all data on /dev/sda. Run this only once during setup, and be absolutely sure it is the empty disk you intend to use. The safety abort above (checking for a Samsung model) helps to a degree, but check it yourself as well.

6. Build a Windows Golden Image + Template

The core of VDI work is getting one "master image" right first, then cloning it into dozens of machines. The sequence is a fresh Windows installation -> generalize with sysprep -> shrink the disk -> turn it into a template.

Finish setting up Windows (install programs, adjust settings), then generalize it so every clone gets a new SID / MachineGUID / computer name; otherwise, the clones will conflict with one another on the network.

# รันใน Windows master (cmd)
copy unattend.xml C:\Windows\System32\Sysprep\unattend.xml
C:\Windows\System32\Sysprep\sysprep.exe /generalize /oobe /shutdown /unattend:C:\Windows\System32\Sysprep\unattend.xml

The unattend.xml I use skips OOBE, sets user admin / password <PW> (changed later during provisioning), enables AutoLogon, sets ComputerName=* (a random name), uses the Thai region, and sets the UI to en-US.

Do not shut down during specialize

On the first boot after sysprep, Windows reboots on its own (SSH disconnecting during boot = normal). If you force a shutdown too early, the VM breaks with an endless "computer restarted unexpectedly" loop and needs to be reinstalled.

Shrink the disk to 32G before creating the template. The core tools are qemu-nbd + ntfsresize + parted. Clone the master to work-copy 9999 first, leaving the actual master untouched. There are safety aborts at every stage to prevent truncating the disk into its data.

# /root/shrink32.sh (แก่น)
case $((10#$(hostname|tail -c2))) in 1) SRC=9001;; 2) SRC=9002;; 3) SRC=9003;; esac
TMP=9999
qm destroy $TMP --purge --skiplock 2>/dev/null
qm clone $SRC $TMP --name new32g --full          # work-copy เต็ม (ไม่แตะ master)
DISK=$(ls /var/lib/vz/images/$TMP/* | head -1)

modprobe nbd max_part=8
qemu-nbd -c /dev/nbd0 -f raw "$DISK"; sleep 2     # map disk เป็น block device
CNUM=$(lsblk -blno NAME,SIZE,FSTYPE /dev/nbd0 | awk '$3=="ntfs"{print $2,$1}' | sort -rn | head -1 | grep -oE '[0-9]+$')
echo y | ntfsresize -f -s 28G "/dev/nbd0p${CNUM}"   # หด filesystem ก่อน
parted -s /dev/nbd0 resizepart "$CNUM" 30.5GB       # แล้วค่อยหด partition
for pn in $(parted -sm /dev/nbd0 print | awk -F: 'NR>2{print $1}'); do
  [ "$pn" -gt "$CNUM" ] && parted -s /dev/nbd0 rm "$pn"   # ลบ recovery partition หลัง C:
done
qemu-nbd -d /dev/nbd0
qemu-img resize -f raw --shrink "$DISK" 32G          # partition สุดท้ายต้องจบ < 32G ก่อน
qemu-img info "$DISK" | grep 'virtual size'

Convert to templates on both local-dir and ZFS.

qm template 9002            # 900<NN> -> template (ต้นทางของ linked clone)
# ZFS-template: full copy ครั้งเดียว ไว้ clone ตัวที่จะอยู่บน ZFS
qm clone 9002 9502 --name ztmpl --full --storage ss1t02 && qm template 9502

7. Clone a VDI Fleet

Once the templates are ready, I can clone them into a large number of machines. I use linked clones because they are fast and save space (sharing a base with the template), with a quota per node for how many go on local-dir and how many on ZFS. Each gets its own VMID = VLAN tag + a mgmt NIC.

master 900NNsysprep completeZFS-template 950NNon the node's zpoolone full copylinked clonelinked cloneFirst LOCAL_N VMslocal-dir, shares master's baseRemaining ZFS_N VMsZFS, shares template's baseLinked clones cannot span storage; ZFS VMs need a template on ZFSEach gets net0 VLAN tag = VMID and net1 on a mgmt bridge with no gateway
VMs where k does not exceed LOCAL_N come from the master; the rest come from the ZFS-template, as defined in clonefleet, and are named win-c followed by the VMID.
Pipeline per 1 VMtemplate->qm clonelinked->tag VLAN= VMID->net1 mgmt->start + provision
# /root/clonefleet.sh (แก่น)
NN=$((10#$(hostname|tail -c2)))
case $NN in
 1) M=9001; LOCAL_N=9;  ZFS_N=26;;
 2) M=9002; LOCAL_N=21; ZFS_N=26;;
 3) M=9003; LOCAL_N=21; ZFS_N=13;;
esac
ZTID=$((9500+NN)); BR=vmbrMGMT; base=$((NN*100))
TOTAL=$((LOCAL_N+ZFS_N))
for ((k=1;k<=TOTAL;k++)); do
  id=$((base+k))
  # k ตัวแรก = local-dir (linked จาก master) ; ที่เหลือ = ZFS (linked จาก ZFS-template)
  if [ $k -le $LOCAL_N ]; then SRC=$M; else SRC=$ZTID; fi
  qm clone $SRC $id --name win-c$id                  # linked clone (เร็ว+ประหยัด)
  qm set $id -net0 "virtio,bridge=vmbrPROD,tag=$id"  # VLAN ต่อเครื่อง (isolation)
  qm set $id -net1 "virtio,bridge=$BR"               # mgmt NIC (ไม่มี gateway)
done
local-dir clone

Linked from master 900X

  • Fast and space-efficient (shares the master's base)
  • Stored on the node's system disk
  • Quota limited by LOCAL_N
ZFS clone

Linked from ZFS-template 950X

  • Stored on a separate zpool (ssXt0X)
  • Linked clones cannot span storage; the source template must be on ZFS
  • Gets lz4 compression + ZFS snapshots
Why the clones do not conflict

Because the master has already run through sysprep /generalize (section 6), every clone gets its own new SID / MachineGUID / computer name on first boot. This is the standard way to deploy Windows images, not any kind of spoofing.

8. Management Network + Pin IPs

I give every clone a second NIC (net1) connected to a mgmt bridge with no gateway for RDP/SSH administration, while production traffic still goes through net0 as usual. Having no gateway on the mgmt network prevents production traffic from taking the wrong route.

# /root/mgmtnet.sh — bridge ไม่มี uplink + DHCP ที่ไม่แจก gateway/DNS
NN=$((10#$(hostname|tail -c2))); BR=vmbrMGMT; SUB=10.77.$NN
grep -q "iface $BR " /etc/network/interfaces || cat >> /etc/network/interfaces <<EOF

auto $BR
iface $BR inet static
    address $SUB.254/24
    bridge-ports none
    bridge-stp off
    bridge-fd 0
EOF
ifup $BR
# option 3 + 6 ว่าง = ไม่มี default route/DNS -> เนตไม่ออกทางนี้
dnsmasq --interface=$BR --bind-interfaces \
  --dhcp-range=$SUB.50,$SUB.250,255.255.255.0,10m \
  --dhcp-option=3 --dhcp-option=6 \
  --except-interface=lo --no-resolv --no-hosts \
  --dhcp-leasefile=/run/dnsmasq-mgmt.leases --pid-file=/run/dnsmasq-mgmt.pid

Map each net1 MAC to 10.77.NN.<VMID%100> (DHCP reservation) so the IP can be inferred from the VMID + check for IP conflicts.

# /root/repin.sh
NN=$((10#$(hostname|tail -c2))); SUB=10.77.$NN; HOSTS=/run/mgmt-hosts.conf; : > "$HOSTS"
for conf in /etc/pve/nodes/"$(hostname)"/qemu-server/*.conf; do
  id=$(basename "$conf" .conf); case "$id" in ''|*[!0-9]*) continue;; esac
  [ "$id" -ge 9000 ] && continue                         # เว้น master/template
  grep -q '^name: win-c' "$conf" || continue
  mac=$(grep -oP 'net1: virtio=\K[0-9A-Fa-f:]+' "$conf" | tr 'A-Z' 'a-z')
  [ -n "$mac" ] && echo "$mac,$SUB.$((id-NN*100))  # VM$id" >> "$HOSTS"
done
pkill -f "dnsmasq.*vmbrMGMT"; sleep 1
dnsmasq --interface=vmbrMGMT --bind-interfaces --dhcp-range="$SUB.20,$SUB.250,255.255.255.0,10m" \
  --dhcp-hostsfile="$HOSTS" --dhcp-option=3 --dhcp-option=6 --except-interface=lo \
  --no-resolv --no-hosts --dhcp-leasefile=/run/dnsmasq-mgmt.leases --pid-file=/run/dnsmasq-mgmt.pid
awk -F, '{print $2}' "$HOSTS" | awk '{print $1}' | sort | uniq -d    # โชว์ IP ชน (ควรว่าง)
Remember

If you re-clone the same VM, its MAC changes -> look up the actual IP in the lease by MAC; do not guess it from the VMID.

9. Provision Windows over SSH

I did not install qemu-guest-agent, so I manage the guests directly over SSH (the Windows OpenSSH default shell is PowerShell, so commands need to be wrapped in cmd /c "...").

  1. qm start Start the VM
  2. Wait for first-ping ~5 minutes Be patient; a fresh Windows first boot is slow
  3. Check for stable ping for 30 seconds + a successful ssh whoami Only then consider it ready
  4. Run provision Apply all settings over SSH
  5. Shut down only on success If provisioning fails, leave it running so you can investigate
# SSH เข้า guest (user/<PW>) ครั้งแรก auto-accept cert, ห่อด้วย cmd /c
ssh -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null [email protected] \
  'cmd /c "powercfg /change standby-timeout-ac 0 & netsh advfirewall firewall set rule group=\"remote desktop\" new enable=Yes"'

The provision.bat disables sleep, enables RDP + firewall access, sets auto-login user/<PW>, clears the cache at boot, then shuts down · I also disable Windows Update permanently because these are VDI machines that I do not want rebooting themselves in the middle of work; that is a matter of preference.

Lessons from actual provisioning

(1) Shutting down too early during sysprep specialize = a broken VM; let it reboot on its own · (2) Run only 1 VM at a time per node; several at once exhaust RAM · (3) sshpass/plink must be available on all 3 nodes · (4) For long/remote jobs, do not run them interactively with a connection held open; put the work in a script, run it in the background, then read the log.

10. Full Execution Order

1. repo-upgrade.sh   -> reboot -> update-grub   (ทีละ node: po003 -> po002 -> po001)
2. pvecm create/add  -> cluster pocluster (3 node quorate)
3. zfsconv.sh        -> ZFS pool + storage ต่อ node
4. [Windows master]  -> sysprep -> shrink32.sh -> qm template (900X) + ZFS-template (950X)
5. delete-all.sh / deletevms.sh   -> ล้าง VM เก่า
6. mgmtnet.sh        -> vmbrMGMT (gateway-less DHCP)
7. clonefleet.sh     -> clone VDI fleet (local + ZFS, tag = VMID)
8. repin.sh          -> mgmt IP เรียงตาม VMID
9. provisionall.sh   -> provision Windows ทีละตัว

To repeat, Claude executed all of this through VS Code; I never touched the Proxmox shell myself, not even once. Every command in this article is an actual command it generated during the work. You can read this and carry it out yourself, or give the article to an AI and tell it to follow the steps one at a time. Adjust the machine count, pool names, or VMID ranges to suit your setup; the principles are the same throughout. If I have left gaps in the explanation or made typos, I will come back and correct them later.

Want us to set this up?

Get help with server setup, configuration, migration, or troubleshooting. Save time for the work that matters.

Get a quote

Hit something like this? Stuck somewhere?

Come trade notes on the LinuxThai board

→ Continue on the board

Comments 0

No comments yet — be the first