Dans la partie 6, on a posé tout le socle “tout en code” : outillage, clé de chiffrement, squelette du dépôt et garde-fous. Zéro serveur allumé, mais des fondations prêtes. Je vous avais promis que la suite passerait enfin au matériel. Nous y sommes, et croyez-moi, on va se salir les mains.
Objectif de cette phase 1 : installer Proxmox VE 9.2 sur les trois nœuds du futur cluster et Proxmox Backup Server sur le quatrième, de façon automatisée via des fichiers de réponse. Le tout en partant de machines qui avaient déjà du Proxmox dessus, donc à nettoyer d’abord.
Et autant vous prévenir tout de suite : l’installation automatique en elle-même est un non-événement, elle se déroule toute seule comme une grande. Le vrai combat de cette phase, celui qui m’a fait perdre le plus de cheveux (et j’en ai déjà pas tant que ça), c’est faire booter le média d’installation en UEFI sur des OptiPlex. On y arrive, mais accrochez-vous.

Le matériel, et une première leçon sur la RAM#
Petit rappel de l’inventaire :
| Rôle | Machine | CPU | RAM | Disques |
|---|---|---|---|---|
| pve01-03 | Dell OptiPlex 7050 Micro | i5 | 32 Go DDR4 | SanDisk 480 Go (OS) + Lexar 512 Go (VM) |
| pbs01 | Dell OptiPlex 9020 Micro | i5 | 8 Go DDR3L | SanDisk 480 Go |
La correction sur la RAM. Dans la partie 5, j’annonçais 24 Go par nœud PVE. Erreur de ma part : après vérification physique, chaque 7050 avait en réalité une barrette de 16 Go et une de 4 Go, soit 20 Go. J’ai donc anticipé en achetant quatre barrettes de 16 Go DDR4 d’occasion (merci leboncoin) : une par PVE pour remplacer la barrette de 4 Go et passer à 2x16 = 32 Go sur les trois nœuds. La quatrième barrette était destinée au 9020 (pbs01), que je comptais monter à 16+4 Go… sauf que je n’avais pas remarqué que le 9020 est en DDR3L, pas en DDR4. Barrette incompatible, donc pbs01 reste à 8 Go pour l’instant ; je commanderai la bonne RAM DDR3L plus tard. Morale : vérifiez le type de RAM (pas seulement la capacité) avant de commander. Ça paraît évident écrit comme ça, un peu moins quand on a le carton ouvert devant soi.

Aparté : le JetKVM, ma console déportée#
Avant d’aller plus loin, un mot sur un petit boîtier qui va revenir en boucle dans cet article : le JetKVM. Je l’avais évoqué dans la partie 5, il est temps de le présenter correctement, parce que sans lui toute cette phase aurait été bien plus pénible.
Un JetKVM, c’est un KVM-over-IP miniature. Concrètement, c’est un petit dongle qui se branche sur deux ports de la machine cible :
- Sur le HDMI, pour capturer l’image de l’écran.
- Sur un port USB, qui joue un double rôle : il alimente le boîtier (pas besoin d’alimentation séparée) et lui sert à se faire passer pour un clavier, une souris, et au besoin un périphérique de stockage (pour monter une ISO).

Le boîtier expose ensuite une interface web accessible depuis le réseau. En ouvrant cette page depuis mon ordi, je vois l’écran de la machine et je la pilote comme si j’étais physiquement devant, y compris dans le BIOS ou avant même qu’un OS ait démarré. C’est toute la différence avec un simple accès SSH, qui suppose que la machine soit déjà démarrée et jointe au réseau.

Dans mon cas d’usage, il coche trois cases précieuses pour cette phase :
- Monter l’ISO Proxmox à distance : je pousse l’image depuis mon poste, elle apparaît sur la machine cible comme un lecteur CD/DVD ou une clé USB virtuelle. Plus besoin de me déplacer avec une clé USB à chaque réinstallation.
- Piloter le BIOS et l’installation sans écran ni clavier physiques branchés sur des mini-PC empilés dans une baie.
- Garder un accès console même quand la machine ne répond plus (kernel panic, mauvais réglage réseau…), là où le SSH, lui, serait déjà perdu.

Bref, c’est l’outil qui rend le “tout piloter à distance” possible sur du matériel qui n’a rien de serveur. Il a aussi ses limites, et vous allez voir qu’il m’a donné du fil à retordre pendant la bataille UEFI, mais globalement c’est un compagnon fidèle de cette phase.
Étape 1 : les secrets de bootstrap#
Fidèle au principe de la phase 0, tout ce qui est sensible est généré puis chiffré avec SOPS avant même de commencer.
D’abord, une clé SSH dédiée au lab, sans passphrase. Choix assumé : l’automatisation a besoin d’une clé utilisable sans intervention, et la protection vient du chiffrement au repos (SOPS + age), pas d’une passphrase.
ssh-keygen -t ed25519 -C "kentrowlab" -f "$HOME/.ssh/kentrowlab_ed25519" -N ""
chmod 600 "$HOME/.ssh/kentrowlab_ed25519"Ensuite, un mot de passe root généré aléatoirement puis haché en yescrypt. Comme mkpasswd n’existe pas sur macOS, je passe par un conteneur :
PW=$(openssl rand -base64 24)
HASH=$(printf '%s\n' "$PW" | docker run --rm -i debian:trixie bash -c \
'apt-get update -qq && apt-get install -y -qq whois && mkpasswd -m yescrypt -s')Reste à ranger tout ça dans SOPS. Petite subtilité : le hash yescrypt contient des $, que le shell interpréterait joyeusement si on n’y prenait pas garde. Je passe donc par yq avec strenv(), qui lit les valeurs depuis l’environnement sans les interpréter :
PW="$PW" HASH="$HASH" \
PUB="$(cat ~/.ssh/kentrowlab_ed25519.pub)" \
PRIV="$(cat ~/.ssh/kentrowlab_ed25519)" \
yq -n '
.root_password = strenv(PW) |
.root_password_hashed = strenv(HASH) |
.ssh_public_key = strenv(PUB) |
.ssh_private_key = strenv(PRIV)
' > secrets/bootstrap.sops.yaml
# Chiffrement en place (la clé age est déjà configurée depuis la phase 0)
sops -e -i secrets/bootstrap.sops.yamlLe fichier secrets/bootstrap.sops.yaml contient donc la clé publique et la clé privée SSH, plus le mot de passe root en clair et haché - le tout chiffré, prêt à être commité.
Oui, je stocke aussi la clé privée SSH dans SOPS. C’est cohérent avec le “tout reconstructible depuis git” : le dépôt plus la clé age suffisent à tout retrouver, sans multiplier les racines de confiance à protéger séparément.
Encore un faux positif sur les fichiers chiffrés#
À peine le fichier chiffré ajouté, ansible-lint s’étouffe :
yaml[line-length]: Line too long (656 > 160 characters)
secrets/bootstrap.sops.yaml:4Il découvre le YAML chiffré et se plaint de ses lignes interminables (les blobs base64). Le fix : un fichier .ansible-lint à la racine qui exclut secrets/ et tous les *.sops.* :
# .ansible-lint
exclude_paths:
- secrets/
- "**/*.sops.*"Plus l’exclusion correspondante dans le hook pre-commit, histoire que la même règle vaille en local comme en CI. C’est le troisième faux positif de la série sur les fichiers chiffrés (après le hook SOPS et check-yaml en phase 0). La leçon devient un refrain : tout outil qui parcourt le dépôt doit apprendre à ignorer les *.sops.*.
Étape 2 : wiper pour de vrai#
Les machines tournaient déjà sous Proxmox. Avant de réinstaller, il faut faire table rase. Et attention, wipefs tout seul ne suffit pas : les labels ZFS et LVM survivent à un simple effacement de partitions et viennent hanter l’installation suivante comme un mauvais fantôme.
Je boote sur l’ISO Proxmox, puis Advanced Options → Install Proxmox VE (Debug mode) pour tomber sur un shell.
Petit piège : le premier shell debug est un busybox minimal, sans lsblk. Il faut taper exit pour avancer jusqu’au second shell, lui complet. J’ai mis un moment à comprendre pourquoi on me demandait de “quitter” pour continuer.
Avant de détruire quoi que ce soit, je collecte les infos des machines (on va voir juste après pourquoi c’est capital) :
lsblk -d -o NAME,SIZE,MODEL,SERIAL,TRAN
ls -l /dev/disk/by-id/ | grep -v part
ip -br link
modprobe zfs && zpool importPuis le wipe, commande par commande (la saisie multi-ligne passe mal au travers du JetKVM) :
vgchange -an # désactive LVM
dmsetup remove_all
wipefs -af /dev/sda ; sgdisk --zap-all /dev/sda ; blkdiscard -f /dev/sda
wipefs -af /dev/nvme0n1 ; sgdisk --zap-all /dev/nvme0n1 ; blkdiscard -f /dev/nvme0n1
lsblk # on doit voir des disques nusDeux détails qui m’ont fait tiquer : poweroff échoue (“System has not been booted with systemd”), il faut utiliser poweroff -f. Et surtout, ne jamais toucher sr0 (l’ISO virtuelle du JetKVM) ni la clé USB. Une commande de wipe sur le mauvais disque et c’est reparti pour un tour… au mieux.
Une fois le lsblk final qui affiche des disques nus, sans la moindre partition ni le moindre label résiduel, la machine est propre : on peut passer à l’installation proprement dite, sur des bases saines.
Étape 3 : cibler le bon disque (par numéro de série)#
Voilà le point qui justifie toute la passe de collecte précédente. Mes trois nœuds ont des disques issus du même lot, avec des numéros de série quasi identiques :
NVMe : pve01 NM620XXXXXX668XX
pve02 NM620XXXXXX677XX
pve03 NM620XXXXXX667XXCes numéros de série sont volontairement anonymisés, mais l’écart entre eux est représentatif du réel : seulement deux chiffres d’écart entre les NVMe. Autant dire qu’un filtre avec joker (NM620XXXXXX66*) matcherait plusieurs disques d’un coup.
Et désigner le disque par sda est tout aussi fragile : l’ordre d’énumération des disques peut changer d’un boot à l’autre. La seule méthode fiable, c’est de cibler le disque OS par son ID_SERIAL_SHORT complet dans le fichier de réponse. C’est le genre de détail qui évite d’installer l’OS sur le mauvais disque et de s’en rendre compte trois heures plus tard, avec la tête dans les mains.
La collecte a aussi révélé deux autres choses utiles : l’interface réseau s’appelle enp0s31f6 sur les 7050 mais eno1 sur le 9020 (génération antérieure), et l’ancienne install avait mis l’OS sur le NVMe. On inverse : OS sur le SATA, VM sur le NVMe (plus rapide et plus gros, réservé au futur pool ZFS).
Étape 4 : les fichiers de réponse#
Le principe : je versionne des templates (un par machine, en .toml.tmpl) avec des placeholders à la place des secrets, et un script render.sh vient les remplir à partir de SOPS. Comme ça, le dépôt ne contient que du chiffré, et les fichiers réellement utilisés à l’installation (qui, eux, contiennent le hash root en clair) sont générés à la volée et gitignorés.
Voici à quoi ressemble un template, ici pve01.toml.tmpl :
[global]
keyboard = "fr"
country = "fr"
fqdn = "pve01.ktw.ovh"
mailto = "contact@ktw.ovh"
timezone = "Europe/Paris"
root-password-hashed = "__ROOT_PASSWORD_HASHED__"
root-ssh-keys = ["__SSH_PUBLIC_KEY__"]
[network]
source = "from-answer"
cidr = "192.168.3.11/24"
gateway = "192.168.3.1"
dns = "1.1.1.1"
filter.ID_NET_NAME = "enp0s31f6"
# Cible le SSD SATA par son numéro de série (anonymisé ici). Le NVMe n'est
# jamais mentionné : son pool ZFS sera créé par Ansible en phase 2.
[disk-setup]
filesystem = "ext4"
lvm.maxroot = 100
lvm.maxvz = 0
filter.ID_SERIAL_SHORT = "174464XXXXXX"Quelques points qui comptent dans ce fichier :
root-password-hashedetroot-ssh-keyssont des placeholders (__...__), remplis au rendu depuis SOPS. Le template lui-même ne contient aucun secret, il peut vivre dans git sans souci.filter.ID_SERIAL_SHORTcible le disque OS par son numéro de série complet (tout l’intérêt de l’étape 3).filter.ID_NET_NAMEvautenp0s31f6pour les PVE,eno1pour le PBS.lvm.maxroot = 100etlvm.maxvz = 0: une racine de 100 Go et pas delocal-lvm, pour laisser le reste du disque libre pour Ansible en phase 2.- Le NVMe n’est jamais mentionné, donc l’installeur n’y touche pas du tout.
Le script de rendu est volontairement simple : il déchiffre les secrets une seule fois, en extrait le hash et la clé publique, puis substitue les placeholders dans chaque template :
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
SECRETS="$SCRIPT_DIR/../secrets/bootstrap.sops.yaml"
OUT="$SCRIPT_DIR/rendered"
umask 077
mkdir -p "$OUT"
PLAIN="$(sops -d "$SECRETS")"
HASH="$(printf '%s' "$PLAIN" | yq -r '.root_password_hashed')"
PUBKEY="$(printf '%s' "$PLAIN" | yq -r '.ssh_public_key')"
unset PLAIN
for tmpl in "$SCRIPT_DIR"/*.toml.tmpl; do
out="$OUT/$(basename "$tmpl" .tmpl)"
sed -e "s|__ROOT_PASSWORD_HASHED__|${HASH}|" \
-e "s|__SSH_PUBLIC_KEY__|${PUBKEY}|" \
"$tmpl" > "$out"
echo "rendered: $out"
doneDeux détails que j’aime bien dans ce script : le umask 077 en tête garantit que les fichiers rendus sortent en permissions 600 (ils contiennent le hash root, autant qu’ils ne soient lisibles que par moi), et le unset PLAIN évite de laisser traîner les secrets déchiffrés dans une variable plus longtemps que nécessaire.
Reste à lancer le rendu et à vérifier qu’il ne reste aucun placeholder :
./answer-files/render.sh
grep -c '__' answer-files/rendered/pve01.toml # 0 = plus aucun placeholderPoint important à ne surtout pas oublier : les fichiers rendus contiennent le hash root en clair, ils n’ont donc rien à faire dans git. On ajoute le dossier de sortie au .gitignore :
# Fichiers de réponse rendus : contiennent le hash root
answer-files/rendered/Ce qui est versionné, ce sont les templates (*.toml.tmpl) et le secret chiffré ; ce qui contient le hash en clair reste local. Fidèle au principe depuis le début : dans git, on ne met que du chiffré ou de l’inoffensif.
Étape 5 : construire les ISO auto-installables#
Pour transformer un fichier de réponse en ISO qui s’installe toute seule, Proxmox fournit l’outil proxmox-auto-install-assistant. Petit hic : c’est un paquet Debian amd64, et je travaille sur un Mac M1 (ARM). Plutôt que de l’installer en dur, je l’enferme dans un conteneur Docker (construit à partir d’un Dockerfile versionné), lancé en émulation linux/amd64. C’est lent, mais on n’est pas à la seconde près, et surtout ça reste reproductible.
Le script build-isos.sh orchestre tout ça : il construit l’image, valide les quatre fichiers de réponse rendus, puis génère les ISO (les trois PVE, puis le PBS) :
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/.." && pwd)"
IMG="kentrowlab/pve-iso-builder"
DOCKER_RUN=(docker run --rm --platform linux/amd64 -v "$ROOT:/work" -w /work "$IMG")
PVE_ISO="${PVE_ISO:-iso/proxmox-ve_9.2-1.iso}"
PBS_ISO="${PBS_ISO:-iso/proxmox-backup-server_4.2-1.iso}"
mkdir -p "$ROOT/build"
docker build --platform linux/amd64 -t "$IMG" "$ROOT/answer-files"
for n in pve01 pve02 pve03 pbs01; do
echo "== validating $n"
"${DOCKER_RUN[@]}" proxmox-auto-install-assistant validate-answer \
"answer-files/rendered/$n.toml"
done
for n in pve01 pve02 pve03; do
echo "== building ISO $n"
"${DOCKER_RUN[@]}" proxmox-auto-install-assistant prepare-iso \
"$PVE_ISO" --fetch-from iso \
--answer-file "answer-files/rendered/$n.toml" \
--output "build/$n-auto.iso"
done
echo "== building ISO pbs01"
"${DOCKER_RUN[@]}" proxmox-auto-install-assistant prepare-iso \
"$PBS_ISO" --fetch-from iso \
--answer-file "answer-files/rendered/pbs01.toml" \
--output "build/pbs01-auto.iso"
ls -lh "$ROOT/build"L’étape de validation est celle qui rassure : chaque fichier passe au crible avant qu’on grave quoi que ce soit.
The answer file was parsed successfully, no errors found! (x4)À la fin, quatre ISO auto-installables m’attendent dans build/ : trois pour les PVE, une pour le PBS. Chacune contient son fichier de réponse et n’a plus qu’à booter sur la bonne machine.

Étape 6 : l’installation, et la bataille du boot UEFI#
Voilà le cœur de l’histoire, celui pour lequel j’ai failli plusieurs fois aller chercher un écran et un clavier physiques en soupirant. Encore une fois : l’auto-installation déroule toute seule et cible le bon disque, c’est un non-événement. Mais faire booter le média virtuel en UEFI sur OptiPlex 7050 a été un vrai combat. Voici la chronologie honnête, parce que c’est là qu’on apprend (et un peu pour que vous ne refassiez pas mes erreurs).
1. pve03, première install. Booté en Legacy avec l’ISO montée en CD/DVD : ça marche du premier coup. Tout content, je ne réalise pas encore le piège que je suis en train de me tendre tout seul.
2. La prise de conscience. Un cluster doit être homogène. Si un nœud est en Legacy et les autres en UEFI, on s’expose à des comportements différents. Décision : tout passer en UEFI. Je réinstalle donc pve03 en UEFI. Facile, non ? Non.
3. pve02 en UEFI : la cascade de blocages.
Selected boot device failedquand le média est monté en CD/DVD (il n’apparaît carrément pas en UEFI).- En montant le média en mode Disk, ça boote… mais le menu “Automated Installation” disparaît, ou l’installeur cherche l’ISO par son identifiant et échoue avec
no device with valid ISO found. - Cerise sur le gâteau : un conflit entre le clavier virtuel du JetKVM et le mass-storage USB. En mode Disk, le clavier virtuel devient muet. Contournement au clavier physique, ou en jouant sur l’ordre de boot. C’est le moment où mon compagnon fidèle m’a un peu lâché.
4. Le flash BIOS. Les 7050 étaient en version 1.8.2, très en retard. Passage en 1.27.0. Nécessaire, mais pas suffisant à lui seul.

5. Ce qui a fini par marcher. La recette gagnante, après pas mal de tâtonnements et quelques jurons : reset usine du BIOS + Legacy Option ROMs décochés + UEFI pur + ISO régénérées fraîches + montage en CD/DVD. Et là, enfin, l’entrée CD/DVD apparaît en UEFI et l’auto-installer se déroule normalement. Victoire.

Une fois la recette trouvée, il n’y avait plus qu’à la rejouer : j’ai appliqué exactement les mêmes réglages sur les trois 7050, et pve01, pve02 et pve03 se sont installés à l’identique, cette fois sans broncher. C’est tout l’intérêt d’avoir sué sur le premier : les suivants deviennent une formalité.

Les réglages BIOS de référence#
Le vrai enseignement de cette phase, c’est qu’il faut figer ses réglages BIOS et son mode de boot AVANT d’installer le premier nœud, pas au troisième. Voici la configuration de référence que j’ai fini par appliquer à l’identique sur toutes les machines :
| Réglage | Valeur |
|---|---|
| Boot List Option | UEFI |
| Enable Legacy Option ROMs | décoché |
| Enable Attempt Legacy Boot | décoché |
| Secure Boot | OFF |
| SATA Operation | AHCI |
| Enable UEFI Network Stack | coché |
| Integrated NIC | Enabled w/PXE |
| Virtualization (VT-x + VT-d) | ON |
| Wake-on-LAN | ON |
| Montage ISO JetKVM | CD/DVD (une fois la NVRAM propre) |
Le mode de boot (UEFI vs Legacy) n’est pas un détail cosmétique : c’est une décision d’architecture qui doit être consciente et uniforme sur tout le cluster. Mon erreur a été de laisser le premier nœud décider à ma place parce que “ça marchait”.
pbs01 : une bonne vieille clé USB#
Pour le 9020, le choix a été vite fait, mais pas vraiment par envie : le JetKVM ne prend que du HDMI en entrée, or ce vieux 9020 ne sort qu’en DisplayPort et VGA. Sans acheter un adaptateur exprès, impossible de le brancher sur le JetKVM. Tant pis, je sors l’écran et le clavier physiques, et j’en profite pour éviter de rejouer la bataille UEFI du JetKVM. Une clé USB flashée boote d’ailleurs sans le moindre caprice en UEFI :
diskutil list
diskutil unmountDisk /dev/diskN
sudo dd if=build/pbs01-auto.iso of=/dev/rdiskN bs=4m status=progress
diskutil eject /dev/diskN(J’en ai profité pour flasher le BIOS du 9020 de A07 à A19, avec les mêmes réglages de référence.) Parfois, la vieille méthode qui marche du premier coup fait un bien fou.
Étape 7 : les vérifications post-install#
Après chaque reboot (média éjecté), un petit contrôle depuis le Mac. On nettoie d’abord la clé d’hôte connue (réinstall sur une IP déjà vue), puis on vérifie le mode de boot, le hostname, les disques et la RAM :
ssh-keygen -R <ip>
ssh -i ~/.ssh/kentrowlab_ed25519 root@<ip> \
"[ -d /sys/firmware/efi ] && echo UEFI || echo LEGACY; hostname -f; lsblk; free -h"Résultats obtenus :
| Nœud | IP | UEFI | RAM | OS / disques |
|---|---|---|---|---|
| pve01 | .11 | 31 Gi | sda + /boot/efi, root 100 Go, NVMe vierge | |
| pve02 | .12 | 31 Gi | sda + /boot/efi, root 100 Go, NVMe vierge | |
| pve03 | .13 | 31 Gi | sda + /boot/efi, root 100 Go, NVMe vierge | |
| pbs01 | .20 | 7.7 Gi | sda + /boot/efi, root 422 Go (voir écart) |
Points validés partout : boot en UEFI (/boot/efi présent), OS bien sur le SATA, NVMe intact et vierge (réservé au pool ZFS de la phase 2), accès par clé SSH du premier coup (clé publique injectée à l’install), et FQDN correct. Les interfaces web répondent : PVE sur :8006, PBS sur :8007.
Un écart à corriger en phase 2#
Sur pbs01, la racine occupe tout le disque (422 Go) alors que mon lvm.maxroot visait à réserver le reste pour le datastore. Un même fichier de réponse ne se comporte pas strictement à l’identique entre PVE et PBS. Rien de bloquant (le datastore PBS peut vivre dans un répertoire sur la racine), mais je le corrigerai proprement en phase 2 avec Ansible.
Bon réflexe à garder : ne jamais supposer qu’un fichier de réponse partagé produit un résultat identique sur deux produits différents. On vérifie toujours le partitionnement réellement obtenu.
L’état des lieux après la phase 1#
- Trois nœuds PVE (pve01-03) : Proxmox VE 9.2, UEFI, 32 Go, BIOS 1.27, réglages de référence identiques, NVMe vierge prêt pour ZFS
- pbs01 : Proxmox Backup Server, UEFI, 8 Go, BIOS A19
- Les quatre joignables par clé SSH, avec leur FQDN en
ktw.ovh - Datastore PBS à redimensionner en phase 2 (écart sur
maxroot)
Le matériel est enfin debout, homogène et propre. Et surtout, hormis la bataille UEFI (qui, elle, ne se rejoue malheureusement pas en code), ce qui est installé est entièrement décrit par le dépôt : fichiers de réponse, secrets chiffrés, ISO reproductibles. Si une machine a un souci demain, je repars du même point en quelques commandes.
Et maintenant ?#
Les quatre machines tournent, mais elles ne se connaissent pas encore entre elles. Dans la partie 8, on passe à Ansible : dépôts no-subscription, configuration des hôtes, création du pool ZFS sur les NVMe, et enfin le montage du cluster à trois nœuds. C’est le moment où quatre machines isolées deviennent un vrai cluster.
À bientôt !




