Après deux ans à créer des VMs et LXC à la main dans l’interface Proxmox, un schéma s’est installé : tu te souviens que tu as créé un container pour tester quelque chose, tu ne te souviens plus de ses paramètres exacts, et quand tu veux le recréer ailleurs ou l’auditer, c’est une chasse aux informations dans l’interface. Tu lis la configuration dans les panneaux Proxmox, tu notes quelque chose, tu espères ne rien oublier.
L’Infrastructure as Code appliquée au homelab, c’est simplement inverser le flux : la définition de ce qui doit exister vit dans des fichiers versionnés dans Git, et l’outil (Terraform) se charge de faire coïncider l’état réel avec cette définition.
J’avais déjà Ansible pour le provisioning des VMs – installer des packages, déployer des configs, gérer les services. Terraform vient avant : il déclare quelles machines doivent exister, avec quels paramètres réseau et ressources. Les deux sont complémentaires et ne font pas le même travail.
Choix du provider Proxmox
Deux providers Terraform pour Proxmox coexistent dans l’écosystème :
Telmate/proxmox: le plus ancien, longtemps le seul disponible. Maintenance ralentie depuis 2023, support Proxmox 8.x partiel.bpg/proxmox: maintenu activement, supporte Proxmox 8.x complètement, API plus complète, mises à jour régulières.
J’utilise bpg/proxmox. La version 0.66.x au moment de cet article.
Créer le token API Proxmox
Terraform a besoin d’un token API Proxmox pour interagir avec le cluster. Ne pas utiliser root – créer un utilisateur dédié avec les droits minimaux nécessaires.
# Sur le nœud Proxmox, en root
pveum user add terraform@pve --comment "Terraform IaC user"
pveum role add TerraformRole --privs
"VM.Allocate VM.Clone VM.Config.CDROM VM.Config.CPU VM.Config.Cloudinit
VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network
VM.Config.Options VM.Monitor VM.Audit VM.PowerMgmt
Datastore.AllocateSpace Datastore.Audit
SDN.Use Pool.Audit Sys.Audit"
pveum acl modify / --user terraform@pve --role TerraformRole --propagate 1
# Créer le token API
pveum user token add terraform@pve homelab --privsep 0
La dernière commande affiche le token secret une seule fois. Le noter immédiatement dans un gestionnaire de secrets (Vaultwarden dans mon cas).
Le --privsep 0 désactive la séparation de privilèges du token, ce qui lui donne les mêmes droits que l’utilisateur. Avec --privsep 1 (défaut), le token ne peut accéder qu’aux endpoints autorisés explicitement – ce qui bloque certaines opérations du provider bpg/proxmox sans message d’erreur clair. Trouver ce comportement m’a coûté une après-midi.
Structure du projet
terraform/
├── providers.tf
├── variables.tf
├── lxc-pihole.tf
├── vm-ubuntu.tf
└── terraform.tfvars # non versionné (.gitignore)
Le state est stocké localement (terraform.tfstate). Pour un homelab à un seul opérateur, un backend remote (S3, Consul) est overkill et ajoute une dépendance. Le fichier state vit dans le répertoire du projet, versionné dans Git uniquement quand il n’y a pas de secrets dedans – en pratique, j’ajoute terraform.tfstate* au .gitignore et je fais des backups manuels vers le NAS.
providers.tf
# terraform/providers.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "~> 0.66"
}
}
}
provider "proxmox" {
endpoint = var.proxmox_endpoint
api_token = var.proxmox_api_token
insecure = true # certificat auto-signé Proxmox
ssh {
agent = true
username = "root"
}
}
Le bloc ssh est nécessaire pour certaines opérations du provider bpg/proxmox qui ne passent pas par l’API REST mais directement par SSH – notamment la copie de fichiers lors du provisioning Cloud-init.
variables.tf
# terraform/variables.tf
variable "proxmox_endpoint" {
type = string
description = "URL de l'API Proxmox"
default = "https://10.10.1.1:8006"
}
variable "proxmox_api_token" {
type = string
sensitive = true
description = "Token API Proxmox au format 'user@realm!tokenid=secret'"
}
variable "proxmox_node" {
type = string
description = "Nom du nœud Proxmox cible"
default = "heighliner"
}
terraform.tfvars (non versionné)
# terraform/terraform.tfvars - dans .gitignore
proxmox_api_token = "terraform@pve!homelab=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
Exemple 1 : LXC Pi-hole déclaratif
# terraform/lxc-pihole.tf
resource "proxmox_virtual_environment_lxc" "pihole" {
node_name = var.proxmox_node
vm_id = 150
tags = ["dns", "infrastructure", "terraform"]
description = "Pi-hole DNS sinkhole - géré par Terraform"
initialization {
hostname = "pihole"
ip_config {
ipv4 {
address = "10.10.1.150/24"
gateway = "10.10.1.1"
}
}
user_account {
password = var.pihole_root_password
keys = [var.ssh_public_key]
}
}
cpu {
cores = 1
}
memory {
dedicated = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
network_interface {
name = "eth0"
bridge = "vmbr0"
vlan_id = 10 # VLAN LAN
}
operating_system {
template_file_id = "local:vztmpl/debian-12-standard_12.7-1_amd64.tar.zst"
type = "debian"
}
start_on_boot = true
started = true
unprivileged = true
}
Ajouter les variables correspondantes dans variables.tf :
variable "pihole_root_password" {
type = string
sensitive = true
}
variable "ssh_public_key" {
type = string
}
Exemple 2 : VM Ubuntu via template Cloud-init
Pour les VMs, le workflow est différent : Terraform clone un template Cloud-init créé à la main une seule fois sur Proxmox. Le template est créé manuellement, puis Terraform s’en sert comme base pour créer des VMs configurables.
Créer le template manuellement (une fois) :
# Sur le nœud Proxmox
wget -q https://cloud-images.ubuntu.com/noble/current/noble-server-cloudimg-amd64.img
-O /tmp/noble-cloudimg.img
qm create 9000
--name ubuntu-24.04-cloud
--memory 1024
--cores 1
--net0 virtio,bridge=vmbr0
qm importdisk 9000 /tmp/noble-cloudimg.img local-lvm
qm set 9000
--scsihw virtio-scsi-pci
--scsi0 local-lvm:vm-9000-disk-0
--ide2 local-lvm:cloudinit
--boot c
--bootdisk scsi0
--serial0 socket
--agent enabled=1
qm template 9000
Ce template (VM ID 9000) ne démarre jamais directement – il sert uniquement de base pour les clones.
Déclarer la VM dans Terraform :
# terraform/vm-ubuntu.tf
resource "proxmox_virtual_environment_vm" "ubuntu_test" {
node_name = var.proxmox_node
vm_id = 201
name = "ubuntu-test"
tags = ["ubuntu", "test", "terraform"]
description = "VM de test Ubuntu 24.04 - géré par Terraform"
clone {
vm_id = 9000 # template Cloud-init
full = true
}
cpu {
cores = 2
sockets = 1
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
}
disk {
datastore_id = "local-lvm"
interface = "scsi0"
size = 20
discard = "on"
ssd = true
}
network_device {
bridge = "vmbr0"
vlan_id = 30 # VLAN Serveurs
model = "virtio"
}
initialization {
ip_config {
ipv4 {
address = "10.10.30.201/24"
gateway = "10.10.30.1"
}
}
user_account {
username = "ubuntu"
password = var.vm_default_password
keys = [var.ssh_public_key]
}
dns {
servers = ["10.10.1.150"] # Pi-hole
domain = "arewel.com"
}
}
agent {
enabled = true
}
on_boot = true
}
Utiliser Terraform
Initialiser le projet :
cd terraform/
terraform init
Initializing the backend...
Initializing provider plugins...
- Finding bpg/proxmox versions matching "~> 0.66"...
- Installing bpg/proxmox v0.66.3...
- Installed bpg/proxmox v0.66.3 (signed, key ID ...)
Terraform has been successfully initialized!
Planifier les changements :
terraform plan
Exemple d’output pour la création du LXC Pi-hole :
Terraform will perform the following actions:
# proxmox_virtual_environment_lxc.pihole will be created
+ resource "proxmox_virtual_environment_lxc" "pihole" {
+ id = (known after apply)
+ node_name = "heighliner"
+ started = true
+ tags = ["dns", "infrastructure", "terraform"]
+ vm_id = 150
+ cpu {
+ cores = 1
}
+ disk {
+ datastore_id = "local-lvm"
+ size = 8
}
+ initialization {
+ hostname = "pihole"
...
}
+ memory {
+ dedicated = 512
}
}
Plan: 1 to add, 0 to change, 0 to destroy.
terraform plan ne fait rien – il prédit uniquement. L’appliquer :
terraform apply
Terraform demande confirmation avant d’agir. Taper yes. La création du LXC prend environ 90 secondes :
proxmox_virtual_environment_lxc.pihole: Creating...
proxmox_virtual_environment_lxc.pihole: Still creating... [10s elapsed]
proxmox_virtual_environment_lxc.pihole: Creation complete after 1m23s [id=heighliner/150]
Apply complete! Resources: 1 added, 0 changed, 0 destroyed.
Le LXC 150 est maintenant visible dans l’interface Proxmox, démarré, avec les paramètres définis dans lxc-pihole.tf.
Pour supprimer proprement une ressource :
terraform destroy -target proxmox_virtual_environment_lxc.pihole
terraform destroy sans -target détruira toutes les ressources gérées par Terraform dans ce projet. Toujours utiliser -target pour supprimer une ressource spécifique, sauf si tu veux vraiment tout détruire.
La frontière entre Terraform et Ansible
Une confusion fréquente quand on commence avec ces deux outils : à quoi sert chacun ?
Terraform répond à la question « qu’est-ce qui doit exister ? » : un LXC avec tels paramètres réseau, telle mémoire, tel template de base. Il est idempotent sur l’état déclaré – si le LXC existe déjà avec les bons paramètres, terraform apply ne fait rien.
Ansible répond à « comment configurer ce qui existe ? » : installer Pi-hole dans ce LXC, configurer ses listes de blocage, créer le fichier /etc/pihole/setupVars.conf avec les bons paramètres. Il agit sur une machine qui tourne déjà.
Le workflow naturel dans mon homelab est devenu :
- Écrire la définition Terraform de la nouvelle machine
terraform apply– la machine existe, elle tourne, on peut s’y connecter en SSH- Passer un rôle Ansible sur l’IP de la nouvelle machine – le service est installé et configuré
Terraform ne sait pas ce qui tourne à l’intérieur d’une VM. Ansible ne sait pas comment la VM a été créée. Chacun reste dans son couloir, et les deux fichiers (.tf et playbook Ansible) sont versionnés dans Git.
Ajouter une output Terraform qui imprime l’IP de chaque machine créée facilite l’intégration avec les playbooks Ansible. Tu peux même utiliser terraform output -json pour générer dynamiquement un inventaire Ansible.
Ce qui a coincé
La liste des permissions API nécessaires pour le provider bpg/proxmox n’est pas documentée de façon exhaustive dans le README du provider. La documentation principale liste les permissions génériques, mais certaines opérations spécifiques (Cloud-init, resize de disque, configuration VLAN) nécessitent des droits supplémentaires.
La première fois que j’ai essayé de créer une VM via un clone Cloud-init, Terraform s’est arrêté avec une erreur cryptique :
Error: unable to clone VM
...
500 Internal Server Error: ... permission check failed (403 Permission check failed)
La permission manquante était VM.Config.Cloudinit. Je l’ai trouvée en cherchant dans les issues GitHub du provider – pas dans la documentation principale. La liste complète que j’utilise est celle donnée dans la section « Créer le token API » plus haut – elle inclut déjà VM.Config.Cloudinit après avoir trouvé ce problème.
Le deuxième écueil concerne le state Terraform et les modifications manuelles dans l’interface Proxmox. Si tu modifies un LXC dans l’UI Proxmox (changer la RAM par exemple) sans mettre à jour le fichier .tf correspondant, le prochain terraform apply va détecter la différence et proposer de remettre la ressource dans l’état déclaré. C’est le comportement attendu – c’est la définition même de l’IaC – mais ça peut surprendre si tu n’as pas l’habitude.
La règle que je me suis fixée : toute modification d’une ressource gérée par Terraform se fait dans le fichier .tf, jamais dans l’interface Proxmox. Pour tester rapidement une option dans l’UI (avant de la déclarer), terraform state rm permet de sortir temporairement une ressource du state sans la détruire.
Résultat
Créer un nouveau LXC avec la configuration complète (réseau, ressources, SSH key) prend maintenant 2 minutes : écrire 20-30 lignes de HCL, terraform apply, le container est là.
L’état de l’infrastructure est versionné dans Git. Un git log terraform/ donne l’historique de toutes les machines créées, modifiées ou supprimées, avec qui a fait le changement et pourquoi. Pour un homelab solo, c’est surtout utile pour retrouver « comment j’avais configuré cette VM il y a 8 mois » sans fouiller dans l’interface Proxmox.
La prochaine étape que j’envisage : utiliser un inventaire Ansible dynamique généré depuis le state Terraform, pour que les playbooks de provisioning s’appliquent automatiquement sur les nouvelles machines au moment de leur création.