Proxmox VE 9 — Developer Workstation (V14)
This is a translation of my documentation into my native language, performed by AI. I have attached the original automated scripts in my language (French).
Debian 13 (Trixie) · Btrfs RAID1 · UEFI HA · Snapper + grub-btrfs · vmbr0 NAT/DHCP
Date: 2026-03-06
Version: V14.5 (complete manual procedure + S1 → S8 scripts in appendix)
Table of Contents
1. Goals and architecture
This documentation describes the setup of a developer-workstation running Proxmox VE 9 based on:
Why "persistent" subvolumes in addition to the snapshotable root?
When booting on a snapshot, the root / is a Btrfs snapshot. Some services (Proxmox, display manager, etc.) need to write to specific directories at boot.
We therefore isolate critical directories in dedicated subvolumes, mounted separately and thus persistent.
Proxmox subvolumes (created before the proxmox-ve installation):
Display manager subvolume (if graphical interface):
2. Prerequisites
3. Manual installation (S1 → S8)
This section is a copy-paste procedure, in S1 → S8 order.
3.1 (S1) Boot on Debian Live + tools
3.2 (S1) Installation variables (hostname, user, network)
3.3 (S1) Interactive disk selection (DESTRUCTIVE)
3.4 (S1) GPT + EFI + SWAP + BTRFS_RAID partitioning
Layout:
3.5 (S1) Create Btrfs RAID1 + base subvolumes
3.6 (S1) Mount + debootstrap
3.7 (S1) Prepare and run the chroot (Debian + HA GRUB configuration)
Create the "inside-chroot" script:
Run the chroot:
Clean exit + reboot:
This is a translation of my documentation into my native language, performed by AI. I have attached the original automated scripts in my language (French).
Debian 13 (Trixie) · Btrfs RAID1 · UEFI HA · Snapper + grub-btrfs · vmbr0 NAT/DHCP
Date: 2026-03-06
Version: V14.5 (complete manual procedure + S1 → S8 scripts in appendix)
Warning — destructive: this procedure completely wipes the two selected disks.
Table of Contents
- 1. Goals and architecture
- 2. Prerequisites
- 3. Manual installation (S1 → S8)
- 4. Operations (diagnostics, rollback, disk replacement)
- 5. Automated installation (scripts)
- 6. Appendices: complete script code V14
1. Goals and architecture
This documentation describes the setup of a developer-workstation running Proxmox VE 9 based on:
- Debian 13 (Trixie) installed via debootstrap
- Btrfs RAID1 (data + metadata in RAID1)
- UEFI HA: 2 EFI partitions (/boot/efi + /boot/efi2) synchronized
- SWAP failover: SWAP1 → SWAP2 via a systemd service
- Automatic Btrfs disk replacement (missing disk + blank disk detection)
- Bootable snapshots via Snapper + grub-btrfs (GRUB menu)
- Network
- one "WAN" interface (external network)
- a vmbr0 bridge without physical interface (NAT + DHCP via dnsmasq) for VM/LXC
- Boot optimization: reduced NetworkManager-wait-online delay (5 seconds)
Why "persistent" subvolumes in addition to the snapshotable root?
When booting on a snapshot, the root / is a Btrfs snapshot. Some services (Proxmox, display manager, etc.) need to write to specific directories at boot.
We therefore isolate critical directories in dedicated subvolumes, mounted separately and thus persistent.
Proxmox subvolumes (created before the proxmox-ve installation):
- /var/lib/vz → @var_lib_vz
- /var/lib/pve-cluster → @var_lib_pve_cluster
- /var/lib/corosync → @var_lib_corosync
- /var/lib/pve-manager → @var_lib_pve_manager
Display manager subvolume (if graphical interface):
- GNOME: /var/lib/gdm3 → @gdm3
- KDE: /var/lib/sddm → @sddm
- XFCE / LMDE7: /var/lib/lightdm → @Lightdm
Important note: /etc/pve is a FUSE mount managed by pve-cluster.
Do not create a Btrfs subvolume for /etc/pve.
2. Prerequisites
- UEFI machine
- 2 physical disks (identical size, or replacement disk ≥ original disk)
- Internet access
- Debian 13 Live ISO (Trixie)
- Information to prepare:
- short hostname (e.g. prox-dev1)
- FQDN (e.g. prox-dev1.local)
- static IP of the Proxmox host
- WAN interface (e.g. ens18)
3. Manual installation (S1 → S8)
This section is a copy-paste procedure, in S1 → S8 order.
Convention: run as root (sudo su) when indicated.
You can copy-paste block by block.
3.1 (S1) Boot on Debian Live + tools
Code:
sudo su
set -euo pipefail
apt update
apt install -y gdisk btrfs-progs debootstrap bc curl wget vim git sudo iptables rsync grub-efi-amd64 shim-signed efibootmgr udev parted
3.2 (S1) Installation variables (hostname, user, network)
Proxmox needs a static IP, and the hostname must be resolved at boot.
We therefore put the host IP in /etc/hosts (not necessarily 127.0.1.1).
Code:
# TO ADAPT
export HOST_SHORT="prox-dev1"
export HOST_FQDN="prox-dev1.local"
export USER_NAME="your_username"
# WAN (static IP of the Proxmox host)
export INSTALL_NET_IF="ens18"
export INSTALL_NET_ADDR="192.168.199.221/24"
export INSTALL_NET_GW="192.168.199.1"
export INSTALL_NET_DNS="1.1.1.1 8.8.8.8"
3.3 (S1) Interactive disk selection (DESTRUCTIVE)
Code:
echo "=== Physical disk detection ==="
mapfile -t CANDIDATES < <(lsblk -dpno NAME,TYPE | awk '$2=="disk"{print $1}')
if [ "${#CANDIDATES[@]}" -lt 2 ]; then
echo "ERROR: fewer than two disks detected."
exit 1
fi
echo "Detected disks:"
MENU_ITEMS=()
for DEV in "${CANDIDATES[@]}"; do
ID_PATH=""
for ID in /dev/disk/by-id/*; do
[ -L "$ID" ] || continue
TARGET=$(readlink -f "$ID")
if [ "$TARGET" = "$DEV" ]; then
ID_PATH="$ID"
break
fi
done
[ -z "$ID_PATH" ] && ID_PATH="$DEV"
SIZE=$(lsblk -dn -o SIZE "$DEV")
MODEL=$(lsblk -dn -o MODEL "$DEV" 2>/dev/null || echo "N/A")
SERIAL=$(udevadm info --query=property --name="$DEV" 2>/dev/null | awk -F= '/^ID_SERIAL=/{print $2}' || echo "")
if [ -n "$SERIAL" ]; then
DESC="$ID_PATH | $SIZE | $MODEL | SN: $SERIAL"
else
DESC="$ID_PATH | $SIZE | $MODEL"
fi
MENU_ITEMS+=("$DEV::$ID_PATH::$DESC")
done
i=0
for ITEM in "${MENU_ITEMS[@]}"; do
i=$((i+1))
IDP=$(echo "$ITEM" | cut -d':' -f3)
echo " $i) $IDP"
done
echo
read -rp "Select disk 1: " CHOICE1
read -rp "Select disk 2: " CHOICE2
if ! [[ "$CHOICE1" =~ ^[0-9]+$ && "$CHOICE2" =~ ^[0-9]+$ ]]; then
echo "ERROR: invalid choices."
exit 1
fi
if [ "$CHOICE1" -eq "$CHOICE2" ]; then
echo "ERROR: choices must be different."
exit 1
fi
MAX=${#MENU_ITEMS[@]}
if [ "$CHOICE1" -lt 1 ] || [ "$CHOICE1" -gt "$MAX" ] || [ "$CHOICE2" -lt 1 ] || [ "$CHOICE2" -gt "$MAX" ]; then
echo "ERROR: choice out of range."
exit 1
fi
SEL1="${MENU_ITEMS[$((CHOICE1-1))]}"
SEL2="${MENU_ITEMS[$((CHOICE2-1))]}"
export DISK1="$(echo "$SEL1" | cut -d':' -f1)"
export DISK2="$(echo "$SEL2" | cut -d':' -f1)"
echo "Disk 1: $DISK1"
echo "Disk 2: $DISK2"
3.4 (S1) GPT + EFI + SWAP + BTRFS_RAID partitioning
Layout:
- p1: EFI (2048 MiB)
- p2: SWAP (RAM × 1.5) — label SWAP1 / SWAP2
- p3: BTRFS_RAID (remaining disk space)
The Btrfs label of the volume (created in the next step) is fixed: prox_raid1.
Code:
RAM_MB=$(awk '/MemTotal/ {printf "%.0f", $2/1024}' /proc/meminfo)
SWAP_MB=$(printf "%.0f" "$(echo "$RAM_MB * 1.5" | bc -l)")
echo "RAM_MB=$RAM_MB SWAP_MB=$SWAP_MB"
i=0
for DISK in "$DISK1" "$DISK2"; do
i=$((i+1))
sgdisk -Z "$DISK"
sgdisk -og "$DISK"
sgdisk -n 1::+2048M -t 1:ef00 -c 1:"EFI" "$DISK"
if [ "$i" -eq 1 ]; then SWAPLABEL="SWAP1"; else SWAPLABEL="SWAP2"; fi
sgdisk -n 2::+${SWAP_MB}M -t 2:8200 -c 2:"$SWAPLABEL" "$DISK"
sgdisk -n 3:: -t 3:8300 -c 3:"BTRFS_RAID" "$DISK"
mkfs.fat -F32 -n EFI "${DISK}1"
done
3.5 (S1) Create Btrfs RAID1 + base subvolumes
V14: do not manually create /.snapshots (Snapper manages it).
Fixed Btrfs label: prox_raid1
The Btrfs filesystem label is intentionally fixed (mkfs.btrfs -L prox_raid1 …).
Commands in this doc (and scripts) use it via mount -L prox_raid1 / blkid -t LABEL="prox_raid1". Do not change it.
Code:
mkfs.btrfs -f -L prox_raid1 -d raid1 -m raid1 "${DISK1}3" "${DISK2}3"
# Create subvolumes at top-level (subvolid=5)
mount -L prox_raid1 -o subvolid=5 /mnt
btrfs subvolume create /mnt/@
btrfs subvolume create /mnt/@home
btrfs subvolume create /mnt/@log
btrfs subvolume create /mnt/@cache
btrfs subvolume create /mnt/@tmp
umount /mnt
3.6 (S1) Mount + debootstrap
V14: Btrfs options without defaults (avoids unwanted interactions on snapshot boot).
Code:
BTRFS_OPTS="noatime,compress=zstd:1"
mount -o $BTRFS_OPTS,subvol=@ -L prox_raid1 /mnt
mkdir -p /mnt/{home,var/{log,cache,tmp},boot/efi,boot/efi2}
mount -o $BTRFS_OPTS,subvol=@home -L prox_raid1 /mnt/home
mount -o $BTRFS_OPTS,subvol=@log -L prox_raid1 /mnt/var/log
mount -o $BTRFS_OPTS,subvol=@cache -L prox_raid1 /mnt/var/cache
mount -o $BTRFS_OPTS,subvol=@tmp -L prox_raid1 /mnt/var/tmp
mount "${DISK1}1" /mnt/boot/efi
mount "${DISK2}1" /mnt/boot/efi2
debootstrap --arch=amd64 trixie /mnt https://deb.debian.org/debian
3.7 (S1) Prepare and run the chroot (Debian + HA GRUB configuration)
Code:
# pseudo-FS
for d in dev proc sys run; do
mount --rbind "/$d" "/mnt/$d"
mount --make-rslave "/mnt/$d"
done
mkdir -p /mnt/sys/firmware/efi/efivars
mount -t efivarfs efivarfs /mnt/sys/firmware/efi/efivars || true
cp -L /etc/resolv.conf /mnt/etc/resolv.conf || true
Create the "inside-chroot" script:
Code:
cat > /mnt/root/inside-chroot-base.sh <<'EOF_CHROOT'
#!/bin/bash
set -euo pipefail
: "${HOST_SHORT:?}"
: "${HOST_FQDN:?}"
: "${USER_NAME:?}"
: "${INSTALL_NET_IF:?}"
: "${INSTALL_NET_ADDR:?}"
: "${INSTALL_NET_GW:?}"
: "${INSTALL_NET_DNS:?}"
echo "$HOST_SHORT" > /etc/hostname
HOST_IP="${INSTALL_NET_ADDR%/*}"
# /etc/hosts: replace the IP on the "FQDN SHORT" line with the host IP
cat > /etc/hosts <<EOF
127.0.0.1 localhost
$HOST_IP $HOST_FQDN $HOST_SHORT
::1 localhost
EOF
cat > /etc/apt/sources.list <<EOF
deb http://deb.debian.org/debian trixie main contrib non-free non-free-firmware
deb http://security.debian.org/debian-security trixie-security main contrib non-free non-free-firmware
deb http://deb.debian.org/debian trixie-updates main contrib non-free non-free-firmware
EOF
apt update
apt install -y locales console-setup
dpkg-reconfigure locales
dpkg-reconfigure keyboard-configuration
apt install -y linux-image-amd64 linux-headers-amd64 grub-efi-amd64 shim-signed efibootmgr btrfs-progs smartmontools rsync sudo vim git curl wget iptables bash-completion openssh-server
cat > /etc/network/interfaces <<EOF
auto lo
iface lo inet loopback
auto $INSTALL_NET_IF
iface $INSTALL_NET_IF inet static
address $INSTALL_NET_ADDR
gateway $INSTALL_NET_GW
dns-nameservers $INSTALL_NET_DNS
EOF
BTRFS_UUID=$(blkid -o value -s UUID -t LABEL="prox_raid1" | sort -u)
EFI1_UUID=$(
blkid -s UUID -o value "$(
lsblk -rpno NAME,FSTYPE,MOUNTPOINT | awk '$2=="vfat" && $3=="/boot/efi"{print $1;exit}'
)"
)
EFI2_UUID=$(
blkid -s UUID -o value "$(
lsblk -rpno NAME,FSTYPE,MOUNTPOINT | awk '$2=="vfat" && $3=="/boot/efi2"{print $1;exit}'
)"
)
BTRFS_OPTS="noatime,compress=zstd:1"
cat > /etc/fstab <<EOF
UUID=$BTRFS_UUID / btrfs $BTRFS_OPTS,subvol=@ 0 0
UUID=$BTRFS_UUID /home btrfs $BTRFS_OPTS,subvol=@home 0 0
UUID=$BTRFS_UUID /var/log btrfs $BTRFS_OPTS,subvol=@log 0 0
UUID=$BTRFS_UUID /var/cache btrfs $BTRFS_OPTS,subvol=@cache 0 0
UUID=$BTRFS_UUID /var/tmp btrfs $BTRFS_OPTS,subvol=@tmp 0 0
UUID=$EFI1_UUID /boot/efi vfat defaults,noatime,nofail 0 2
UUID=$EFI2_UUID /boot/efi2 vfat defaults,noatime,nofail 0 2
EOF
SWAP1_DEV=$(blkid -o device -t PARTLABEL="SWAP1")
SWAP2_DEV=$(blkid -o device -t PARTLABEL="SWAP2")
mkswap -L SWAP1 "$SWAP1_DEV"
mkswap -L SWAP2 "$SWAP2_DEV"
swapon "$SWAP1_DEV"
SWAP1_UUID=$(blkid -s UUID -o value "$SWAP1_DEV")
cat >> /etc/fstab <<EOF
# SWAP-FAILOVER-MANAGED
UUID=$SWAP1_UUID none swap defaults 0 0
EOF
# GRUB: root on Btrfs UUID + subvol + degraded + resume SWAP1
sed -i "s|^GRUB_CMDLINE_LINUX_DEFAULT=.*|GRUB_CMDLINE_LINUX_DEFAULT="quiet resume=UUID=$SWAP1_UUID root=UUID=$BTRFS_UUID rootflags=subvol=@,degraded"|" /etc/default/grub
sed -i "s|^GRUB_CMDLINE_LINUX=.*|GRUB_CMDLINE_LINUX="resume=UUID=$SWAP1_UUID root=UUID=$BTRFS_UUID rootflags=subvol=@,degraded"|" /etc/default/grub
echo btrfs >> /etc/initramfs-tools/modules || true
grub-install --target=x86_64-efi --efi-directory=/boot/efi --bootloader-id=debian-disk1 --removable --recheck
grub-install --target=x86_64-efi --efi-directory=/boot/efi2 --bootloader-id=debian-disk2 --removable --recheck
update-grub
update-initramfs -u -k all
useradd -m -G sudo,adm -s /bin/bash -c "$USER_NAME" "$USER_NAME"
echo
echo "Password for user $USER_NAME:"
passwd "$USER_NAME"
echo
echo "Password for root:"
passwd root
EOF_CHROOT
chmod +x /mnt/root/inside-chroot-base.sh
Run the chroot:
Code:
export HOST_SHORT HOST_FQDN USER_NAME INSTALL_NET_IF INSTALL_NET_ADDR INSTALL_NET_GW INSTALL_NET_DNS
chroot /mnt /root/inside-chroot-base.sh
Clean exit + reboot:
Code:
umount -Rv /mnt || true
reboot