Terraform et Proxmox VE : décrire ses machines virtuelles en code plutôt qu'en clics
Sur un serveur dédié qui fait tourner Proxmox VE, créer une machine virtuelle se fait d'habitude dans l'interface web : assistant de création, choix de l'ISO, du disque, du réseau, puis une installation à la main. À la troisième VM identique, la méthode montre ses limites : personne ne sait plus quelle VM a quel réglage, et reconstruire l'hôte après une panne prend une journée. Terraform règle ce problème en appliquant à l'hyperviseur le principe de l'infrastructure as code (IaC) : les VM, les conteneurs LXC, les images cloud et leur configuration Cloud-Init sont décrits dans des fichiers texte en langage HCL, versionnés dans Git, puis créés, modifiés ou détruits par trois commandes : terraform plan, terraform apply et terraform destroy. Terraform compare ce que décrit le code avec ce qui existe réellement sur le nœud Proxmox, enregistre le résultat dans un fichier d'état (le tfstate) et n'applique que la différence.
Le pont entre Terraform et l'API de Proxmox VE est un provider. Celui que ce guide utilise est bpg/proxmox, projet open source publié sur le registre Terraform et sur GitHub, en version 0.120.0 depuis le 2 novembre 2026. Sa documentation cible Proxmox VE 9.x, précise que Proxmox VE 8.x fonctionne avec quelques limitations sans être une priorité de test, et exclut la 7.x. Côté outil, il demande Terraform 1.6 ou plus récent, ou OpenTofu 1.6 ou plus récent, le fork communautaire de Terraform placé sous l'égide de la Linux Foundation et compatible avec les mêmes fichiers. Tout ce qui suit s'applique aux deux : remplacez simplement terraform par tofu dans les commandes.
Ce guide s'adresse à l'administrateur d'un serveur dédié Proxmox qui veut industrialiser ses déploiements : création d'un utilisateur et d'un token API dédiés à Terraform, téléchargement d'une image cloud Debian 13 ou Ubuntu 24.04, construction d'un template Cloud-Init, ressource proxmox_virtual_environment_vm complète (disque, réseau, clé SSH), puis un conteneur LXC avec proxmox_virtual_environment_container. Nous terminons par le stockage de l'état et par l'enchaînement avec Ansible, déjà couvert sur ce site, pour configurer l'intérieur des machines une fois créées.
Terraform, Ansible, interface web : qui fait quoi
Terraform et Ansible ne sont pas concurrents, ils s'arrêtent à des endroits différents. Terraform crée et détruit des ressources chez un fournisseur (ici l'hyperviseur) et sait exactement ce qu'il a créé grâce à son état. Ansible se connecte en SSH à une machine qui existe déjà et y installe des paquets, des utilisateurs et des fichiers de configuration. Les alternatives d'un administrateur Proxmox se comparent ainsi :
| Méthode | Reproductible | Versionnable dans Git | Détecte une dérive | Configure l'intérieur de la VM |
|---|---|---|---|---|
| Interface web Proxmox | Non, chaque VM est faite à la main | Non | Non | Non (console) |
Commandes qm et pct dans un script shell | Oui, tant que le script ne casse pas | Oui | Non, le script ne sait pas ce qui existe | Non |
| Proxmox VE Helper-Scripts | Oui, pour un service donné | Pas vos paramètres | Non | Oui, pour le service du script |
| Terraform + provider bpg/proxmox | Oui, à l'identique | Oui, c'est le principe | Oui, terraform plan affiche la différence | Première configuration seulement, via Cloud-Init |
| Ansible | Oui, idempotent | Oui | Oui, mode --check | Oui, c'est son rôle |
La combinaison la plus courante, et celle de ce guide, est donc Terraform pour la couche « machines » et Ansible pour la couche « système ». Un agent IA branché sur Proxmox par MCP peut lui aussi créer des VM à la demande, mais sans laisser de description déclarative derrière lui : pour une infrastructure que l'on veut pouvoir reconstruire, le code Terraform reste la référence.
Prérequis
- Un serveur dédié sous Proxmox VE 9, ou 8 en acceptant les limitations annoncées par le provider. Si votre hôte est encore en 8, notre guide migrer Proxmox VE 8 vers 9 décrit le passage. Le nœud utilisé dans les exemples s'appelle
pve1, ses stockages sontlocal(fichiers : ISO, templates, snippets) etlocal-lvm(disques de VM), son pont réseau estvmbr0; - Un poste de contrôle Linux, macOS ou WSL, qui atteint le port 8006 du nœud. Si l'API n'est pas exposée sur Internet, ce qui est la bonne pratique, passez par un VPN ou par Tailscale ;
- Une paire de clés SSH sur ce poste, dont la clé publique sera injectée dans chaque VM par Cloud-Init (créer une clé SSH) ;
- Terraform 1.6+ ou OpenTofu 1.6+. Sur Ubuntu ou Debian, HashiCorp fournit un dépôt apt :
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
terraform versionOpenTofu se distribue en paquets .deb et .rpm, en snap, via Homebrew ou en binaire autonome, depuis la page d'installation du projet. Le binaire s'appelle tofu. Quel que soit l'outil, le poste de contrôle est le seul endroit où le code et l'état seront manipulés : rien ne s'installe sur le nœud Proxmox.
Créer l'utilisateur, le rôle et le token API dédiés à Terraform
Ne faites jamais tourner Terraform avec root@pam : un token qui fuit dans un dépôt Git donnerait tout l'hyperviseur. La documentation du provider décrit un utilisateur terraform@pve, un rôle Terraform et un token. Elle fournit une liste de privilèges qu'elle qualifie elle-même d'exemple, « probablement trop permissive pour la plupart des usages », et signale que la liste des privilèges disponibles a changé avec Proxmox VE 9.0. Nous la reprenons telle quelle, sur un nœud en root :
pveum role add Terraform -privs "Realm.AllocateUser VM.PowerMgmt VM.GuestAgent.Unrestricted Sys.Console Sys.Audit Sys.AccessNetwork VM.Config.Cloudinit VM.Replicate Pool.Allocate SDN.Audit Realm.Allocate SDN.Use Mapping.Modify VM.Config.Memory VM.GuestAgent.FileSystemMgmt VM.Allocate SDN.Allocate VM.Console VM.Clone VM.Backup Datastore.AllocateTemplate VM.Snapshot VM.Config.Network Sys.Incoming Sys.Modify VM.Snapshot.Rollback VM.Config.Disk Datastore.Allocate VM.Config.CPU VM.Config.CDROM Group.Allocate Datastore.Audit VM.Migrate VM.GuestAgent.FileWrite Mapping.Use Datastore.AllocateSpace Sys.Syslog VM.Config.Options Pool.Audit User.Modify VM.Config.HWType VM.Audit Sys.PowerMgmt VM.GuestAgent.Audit Mapping.Audit VM.GuestAgent.FileRead Permissions.Modify"
pveum user add terraform@pve -comment "Compte de service Terraform"
pveum aclmod / -user terraform@pve -role TerraformPour les ressources de ce guide (téléchargement d'images, VM, templates, conteneurs), les privilèges qui touchent aux utilisateurs, aux groupes, aux royaumes d'authentification et aux permissions (User.Modify, Group.Allocate, Realm.Allocate, Realm.AllocateUser, Permissions.Modify) ne servent à rien : retirez-les si vous voulez un rôle plus strict, et vérifiez avec terraform plan que rien ne manque. Le rôle se modifie ensuite avec pveum role modify Terraform -privs "..." sans toucher au token.
Vient le token. La documentation du provider le crée sans séparation des privilèges (--privsep=0) : le token hérite alors exactement des droits de l'utilisateur. Avec la séparation activée, qui est le réglage par défaut de pveum, il faut accorder le rôle au token lui-même en plus de l'utilisateur, comme nous le faisons dans sécuriser et mettre à jour Proxmox VE. Les deux variantes :
# Variante 1 : token sans separation, droits identiques a l'utilisateur
pveum user token add terraform@pve provider --privsep=0
# Variante 2 : token a privileges separes, ACL posee sur le token
pveum user token add terraform@pve provider --privsep=1
pveum aclmod / -token 'terraform@pve!provider' -role TerraformLe secret s'affiche une seule fois. Le provider attend la forme utilisateur@royaume!nom_du_token=secret, soit terraform@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx, dans l'argument api_token ou, mieux, dans la variable d'environnement PROXMOX_VE_API_TOKEN pour qu'il n'apparaisse jamais dans le code. Un token compromis se révoque avec pveum user token delete terraform@pve provider sans toucher au reste.
Faut-il aussi un accès SSH au nœud ? La documentation est précise : l'API suffit pour tout le cycle de vie des VM et des conteneurs, le stockage, les réseaux, les pools, les imports de disque par import_from et le téléchargement de fichiers. SSH n'intervient que pour déposer des snippets (fichiers Cloud-Init personnalisés) ou certains types de fichiers, importer un disque depuis un chemin local, ou configurer les idmap d'un conteneur. Ce guide n'utilise aucune de ces fonctions. Le bloc ssh de la configuration du provider doit tout de même nommer un utilisateur quand on s'authentifie par token (la documentation le marque comme requis dans ce cas), mais il n'est sollicité que pour ces opérations : tant que vous n'y recourez pas, rien n'est à créer sur l'hyperviseur. Le jour où vous en aurez besoin, le provider exige un utilisateur non root avec une règle sudo minimale NOPASSWD: /usr/sbin/pvesm apiinfo, et met en garde contre un NOPASSWD sur l'ensemble de qm ou pvesm, équivalents à root.
Le projet Terraform : provider, variables et token hors du code
Un projet Terraform est un répertoire de fichiers .tf, tous lus ensemble. Créez ~/proxmox-terraform avec un premier fichier providers.tf qui épingle la version du provider. Épingler évite qu'un terraform init sur un autre poste, six mois plus tard, télécharge une version au comportement différent :
terraform {
required_version = ">= 1.6"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.120.0"
}
local = {
source = "hashicorp/local"
}
}
}
provider "proxmox" {
endpoint = var.pve_endpoint
# api_token lu dans PROXMOX_VE_API_TOKEN
insecure = false # true seulement avec le certificat auto-signe d'un lab
ssh {
agent = true
username = "terraform" # requis avec un token, utilise seulement pour les snippets
}
}Puis variables.tf, qui déclare l'adresse de l'API, le nœud et la clé SSH publique à injecter :
variable "pve_endpoint" {
type = string
default = "https://pve1.example.com:8006/"
}
variable "pve_node" {
type = string
default = "pve1"
}
variable "ssh_public_key" {
type = string
description = "Cle publique SSH injectee par Cloud-Init"
}La clé publique se passe dans un fichier terraform.tfvars (ssh_public_key = "ssh-ed25519 AAAA...") et le token dans l'environnement du shell, jamais dans un fichier commité :
export PROXMOX_VE_API_TOKEN="terraform@pve!provider=xxxxxxxx-xxxx-xxxx-xxxx-xxxxxxxxxxxx"
cd ~/proxmox-terraform
terraform initterraform init télécharge le provider dans .terraform/ et écrit .terraform.lock.hcl, le fichier de verrouillage des versions et des sommes de contrôle, à commiter avec le code. Ajoutez dès maintenant un .gitignore avec .terraform/, *.tfstate, *.tfstate.backup et terraform.tfvars : l'état et les variables contiennent des secrets.
Télécharger une image cloud Debian 13 ou Ubuntu 24.04 sur le nœud
Une image cloud est un disque qcow2 déjà installé, livré par la distribution avec Cloud-Init à bord : au premier démarrage, le système lit les paramètres que Proxmox lui présente (utilisateur, clé SSH, adresse IP) et se configure seul, sans assistant d'installation. Debian publie la sienne sous cloud.debian.org/images/cloud/trixie/latest/ en deux saveurs : debian-13-generic-amd64.qcow2 (pilotes pour matériel physique inclus, environ 420 Mo) et debian-13-genericcloud-amd64.qcow2 (allégée pour les hyperviseurs, environ 325 Mo), accompagnées d'un fichier SHA512SUMS. Ubuntu publie noble-server-cloudimg-amd64.img sous cloud-images.ubuntu.com/noble/current/, un fichier qcow2 malgré son extension.
La ressource proxmox_virtual_environment_download_file fait télécharger le fichier par le nœud lui-même, directement depuis l'API, sans transit par votre poste. Le type de contenu import est celui que le provider documente pour les images de VM : il faut au préalable ajouter « Import » aux types de contenu autorisés du stockage local (Datacenter, Storage, local, Edit, Content). Dans images.tf :
resource "proxmox_virtual_environment_download_file" "debian13" {
content_type = "import"
datastore_id = "local"
node_name = var.pve_node
url = "https://cloud.debian.org/images/cloud/trixie/latest/debian-13-genericcloud-amd64.qcow2"
file_name = "debian-13-genericcloud-amd64.qcow2"
overwrite = false
}Trois précisions tirées de la documentation. Avec import, le nom de fichier doit porter la vraie extension du format : pour Ubuntu, renommez en noble-server-cloudimg-amd64.qcow2 via file_name. Les images compressées (.xz) ne passent pas par import_from : il faut alors le type iso, l'argument decompression_algorithm et un nom terminé par .img, le provider important ensuite le disque par SSH. Enfin, un hôte Proxmox VE antérieur à 8.4 refuse les extensions .qcow2 et .raw avec une erreur « wrong file extension » : là aussi, le contournement documenté est le type iso et un nom en .img. Pour une image à version fixe (répertoire daté plutôt que latest), ajoutez checksum et checksum_algorithm = "sha512" avec la valeur lue dans SHA512SUMS.
Construire le template Cloud-Init avec Terraform
Le schéma classique de Proxmox, décrit dans son wiki, consiste à créer une VM à partir de l'image cloud, lui attacher un lecteur Cloud-Init, une console série, puis la convertir en template avec qm template. Terraform fait la même chose avec une ressource proxmox_virtual_environment_vm dont template = true. Dans template.tf :
resource "proxmox_virtual_environment_vm" "debian13_template" {
name = "debian-13-cloudinit"
node_name = var.pve_node
vm_id = 9000
template = true
started = false
tags = ["debian", "template"]
agent {
enabled = false # a passer a true quand qemu-guest-agent tourne dans l'invite
}
cpu {
cores = 2
type = "x86-64-v2-AES"
}
memory {
dedicated = 2048
floating = 2048 # egal a dedicated : ballooning actif
}
disk {
datastore_id = "local-lvm"
import_from = proxmox_virtual_environment_download_file.debian13.id
interface = "scsi0"
iothread = true
discard = "on"
size = 20
}
initialization {
datastore_id = "local-lvm"
user_account {
username = "debian"
keys = [trimspace(var.ssh_public_key)]
}
ip_config {
ipv4 {
address = "dhcp"
}
}
}
network_device {
bridge = "vmbr0"
}
operating_system {
type = "l26"
}
serial_device {}
}Ligne par ligne, ce que fait cette ressource :
template = trueetstarted = false: la VM est convertie en template dès sa création, lecture seule et clonable à volonté. La documentation prévient que la conversion inverse n'est pas prise en charge ;agent.enabled = false: le provider désactive l'agent QEMU par défaut et insiste sur un point : activer l'agent alors queqemu-guest-agentne tourne pas dans l'invité provoque de longs délais d'attente à la création et au rafraîchissement, et fait échouer les arrêts et redémarrages. Beaucoup d'images cloud ne l'embarquent pas ; nous l'installerons avec Ansible, puis basculerons ce réglage ;cpu.type = "x86-64-v2-AES": le type recommandé par le provider pour un processeur moderne, à la place duqemu64par défaut ;memory.floatingégal àdedicatedactive le ballooning ;disk: le disque est importé depuis l'image téléchargée (import_from) surlocal-lvm, en interfacescsi0, aveciothreadetdiscardcomme dans les exemples du provider, et agrandi à 20 Go (la valeur par défaut est 8). Cloud-Init étend la partition racine au premier démarrage ;initialization: c'est le lecteur Cloud-Init.user_accountcrée l'utilisateurdebianavec votre clé publique,ip_configdemande une adresse en DHCP. Le provider attache ce lecteur enide2par défaut, comme le fait l'interface Proxmox ;serial_device {}: une console série, que le wiki Proxmox recommande pour les images cloud, dont beaucoup s'attendent à en trouver une.
Pour aller au-delà de ce que le Cloud-Init natif de Proxmox permet (installer des paquets, écrire des fichiers, définir le fuseau horaire dès le premier démarrage), le provider propose proxmox_virtual_environment_file avec content_type = "snippets" et un bloc #cloud-config, référencé par user_data_file_id à la place de user_account. C'est l'un des rares cas qui exigent l'accès SSH au nœud et un stockage où le type de contenu « Snippets » est activé ; la documentation rappelle aussi qu'un fichier personnalisé remplace tout ce que Proxmox génère, y compris le nom d'hôte, à redéfinir soi-même.
Créer une VM par clonage : disque, réseau, clé SSH
Chaque VM de production est désormais un clone du template, avec ses propres CPU, mémoire, adresse IP et étiquettes. Dans vm.tf :
resource "proxmox_virtual_environment_vm" "web01" {
name = "web01"
node_name = var.pve_node
vm_id = 101
tags = ["debian", "web"]
clone {
vm_id = proxmox_virtual_environment_vm.debian13_template.vm_id
full = true
}
agent {
enabled = false
}
stop_on_destroy = true
cpu {
cores = 4
type = "x86-64-v2-AES"
}
memory {
dedicated = 4096
floating = 4096
}
initialization {
datastore_id = "local-lvm"
user_account {
username = "debian"
keys = [trimspace(var.ssh_public_key)]
}
ip_config {
ipv4 {
address = "192.0.2.11/24"
gateway = "192.0.2.1"
}
}
dns {
servers = ["9.9.9.9", "1.1.1.1"]
}
}
}Le bloc clone désigne le template par son identifiant ; full = true, la valeur par défaut, produit un clone complet, indépendant du template, plutôt qu'un clone lié. Le clone hérite de tout le reste (disque de 20 Go, pont vmbr0, console série, type d'OS) et nous ne redéfinissons que ce qui change. Deux points tirés du guide de clonage du provider : un clone hérite du réglage de l'agent QEMU inscrit dans la configuration Proxmox du template, donc omettre le bloc agent ne désactive pas l'attente de l'agent, il faut écrire enabled = false explicitement ; et stop_on_destroy = true est recommandé pour une VM démarrée sans agent, sinon terraform destroy envoie un arrêt ACPI et attend un invité qui ne répondra pas.
L'adresse IP statique est posée dans ip_config parce que, sans agent, Proxmox ne peut pas remonter une adresse obtenue en DHCP. Les attributs exportés ipv4_addresses et network_interface_names restent vides tant que agent.enabled vaut false. Pour un pont dédié ou un VLAN, remplacez le bloc réseau hérité par un network_device avec bridge et vlan_id, en cohérence avec vos réseaux SDN Proxmox. Pour une deuxième VM, copiez la ressource en changeant name, vm_id et l'adresse : c'est volontairement répétitif au début, et for_each sur une liste de machines viendra quand le motif sera stable.
Créer un conteneur LXC avec proxmox_virtual_environment_container
Un conteneur LXC partage le noyau de l'hôte : il démarre en une seconde, consomme quelques dizaines de mégaoctets et convient à une base de données, un reverse proxy ou un service comme Jellyfin. Il ne part pas d'une image disque mais d'un template (archive du système de fichiers racine). Proxmox en publie sur download.proxmox.com/images/system/ ; la commande pveam update && pveam available --section system sur le nœud affiche le nom exact du moment, par exemple debian-13-standard_13.1-2_amd64.tar.zst. Dans lxc.tf, le téléchargement se fait avec le type de contenu vztmpl :
resource "proxmox_virtual_environment_download_file" "debian13_lxc" {
content_type = "vztmpl"
datastore_id = "local"
node_name = var.pve_node
url = "http://download.proxmox.com/images/system/debian-13-standard_13.1-2_amd64.tar.zst"
}
resource "proxmox_virtual_environment_container" "db01" {
node_name = var.pve_node
vm_id = 201
description = "Gere par Terraform"
unprivileged = true
tags = ["debian", "lxc"]
features {
nesting = true
}
cpu {
cores = 2
}
memory {
dedicated = 1024
swap = 512
}
disk {
datastore_id = "local-lvm"
size = 8
}
operating_system {
template_file_id = proxmox_virtual_environment_download_file.debian13_lxc.id
type = "debian"
}
initialization {
hostname = "db01"
ip_config {
ipv4 {
address = "192.0.2.21/24"
gateway = "192.0.2.1"
}
}
user_account {
keys = [trimspace(var.ssh_public_key)]
}
}
network_interface {
name = "eth0"
bridge = "vmbr0"
}
}Les différences avec la VM sautent aux yeux : pas de Cloud-Init, c'est Proxmox qui écrit le nom d'hôte, l'adresse et la clé SSH du compte root dans le conteneur ; unprivileged = true, que le provider laisse à false par défaut mais que son exemple active en notant que les distributions récentes exigent des espaces de noms utilisateur non privilégiés ; features.nesting, nécessaire à systemd dans un conteneur non privilégié, et le seul drapeau modifiable sans être root@pam d'après la documentation ; un disque racine de 8 Go (4 par défaut) ; memory.swap à 0 par défaut. Le tableau suivant met en regard les deux ressources :
| Élément | VM : proxmox_virtual_environment_vm | LXC : proxmox_virtual_environment_container |
|---|---|---|
| Source | Image cloud qcow2 (content_type = "import") ou clone d'un template | Template vztmpl (.tar.zst) |
| Disque | Bloc disk : import_from, interface, size (8 Go par défaut) | Bloc disk : size (4 Go par défaut) |
| Réseau | network_device : bridge, vlan_id, modèle virtio | network_interface : name obligatoire, bridge (vmbr0 par défaut), vlan_id |
| Compte et clé SSH | initialization.user_account (Cloud-Init) ou user_data_file_id | initialization.user_account : clés et mot de passe du compte root |
| Adresse IP | initialization.ip_config | initialization.ip_config |
| Agent invité | Bloc agent, qemu-guest-agent dans l'invité | Sans objet |
| Démarrage | started, on_boot, stop_on_destroy | started, start_on_boot |
plan, apply, destroy : le cycle de vie Terraform
Le code est complet. Avant toute exécution, mettez-le en forme et validez sa syntaxe, puis demandez le plan :
terraform fmt
terraform validate
terraform plan -out=pve1.tfplanterraform plan interroge l'API, compare avec l'état (vide la première fois) et affiche ce qu'il compte faire : « Plan: 5 to add, 0 to change, 0 to destroy », avec chaque attribut de chaque ressource. Terraform déduit l'ordre des dépendances : l'image avant le template, le template avant le clone, le template LXC avant le conteneur. Rien n'est encore touché. Si le plan vous convient, appliquez exactement ce plan :
terraform apply pve1.tfplanLe nœud télécharge les images, importe le disque, convertit le template, clone, crée le conteneur et démarre les deux invités. Quelques minutes plus tard, ssh debian@192.0.2.11 et ssh root@192.0.2.21 répondent avec votre clé. Relancez terraform plan : il doit annoncer « No changes ». C'est le test d'idempotence, et c'est aussi votre détecteur de dérive : si quelqu'un change la RAM de web01 dans l'interface web, le prochain plan le signale et apply rétablit la valeur du code. Les commandes du quotidien :
| Commande | Effet |
|---|---|
terraform plan | Affiche la différence entre le code, l'état et la réalité, sans rien modifier |
terraform apply | Applique la différence après confirmation (ou sans, avec un plan enregistré) |
terraform state list | Liste les ressources connues de l'état |
terraform show | Affiche l'état complet, attributs exportés compris |
terraform destroy -target=proxmox_virtual_environment_vm.web01 | Détruit une seule ressource |
terraform destroy | Détruit tout ce que ce projet a créé, dans l'ordre inverse des dépendances |
terraform import | Rattache à l'état une VM créée à la main, pour la gérer ensuite par code |
Modifier une VM se fait dans le code : passez cores de 4 à 6, plan annonce « 1 to change ». Le guide du cycle de vie du provider précise que certains attributs s'appliquent à chaud (une augmentation de memory.dedicated si le hotplug mémoire est activé), que d'autres demandent un redémarrage (cpu.cores), géré automatiquement par reboot_after_update, à true par défaut, et que d'autres encore forcent une recréation. Lisez toujours la colonne du plan : un -/+ signifie destruction puis recréation, donc perte du disque. Pour une VM qui ne doit jamais être détruite par erreur, posez protection = true : Proxmox refusera la suppression tant que le drapeau est en place.
Stocker et protéger l'état Terraform (tfstate)
Le fichier terraform.tfstate est la mémoire de Terraform : identifiants des VM, attributs, et tout ce que le provider a lu ou écrit, mots de passe Cloud-Init compris, en clair. Le perdre revient à perdre le lien entre le code et les machines ; le laisser traîner revient à publier vos secrets. Par défaut, il est écrit en local dans le répertoire du projet. Quatre options, de la plus simple à la plus robuste :
| Stockage de l'état | Pour qui | Verrouillage | Remarques |
|---|---|---|---|
| Fichier local (par défaut) | Un seul administrateur, un seul poste | Verrou local | À exclure de Git, à sauvegarder chiffré hors site |
Backend s3 sur un stockage compatible S3 (MinIO, Garage, service objet) | Plusieurs postes ou une CI | use_lockfile = true (natif S3 depuis Terraform 1.10, DynamoDB déprécié) | L'état reste en clair dans le bucket : chiffrement côté serveur et accès restreint |
Backend pg (PostgreSQL) | Équipe disposant déjà d'une base | Natif | Une base à sauvegarder, pas de bucket |
OpenTofu : n'importe lequel + encryption | Tous | Selon le backend | État chiffré au repos, local ou distant, depuis OpenTofu 1.7 |
Le backend S3 se déclare dans le bloc terraform, puis terraform init propose de migrer l'état existant. Le verrou natif, introduit par Terraform 1.10 en novembre 2024, remplace la table DynamoDB dont la documentation annonce la suppression dans une version mineure à venir. Un service compatible S3 auto-hébergé demande en plus l'adresse du point d'accès et l'adressage par chemin, détaillés dans la documentation du backend :
terraform {
backend "s3" {
bucket = "tfstate"
key = "proxmox/pve1.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}Avec OpenTofu, le chiffrement de l'état se configure dans le même bloc : un fournisseur de clé (phrase secrète PBKDF2 d'au moins 16 caractères, AWS KMS, GCP KMS, Azure Key Vault, OpenBao ou un programme externe), une méthode aes_gcm et son application à l'état. Le fichier devient illisible sans la clé, où qu'il soit stocké :
terraform {
encryption {
key_provider "pbkdf2" "etat" {
passphrase = var.passphrase # variable sensitive, lue dans TF_VAR_passphrase
}
method "aes_gcm" "etat" {
keys = key_provider.pbkdf2.etat
}
state {
method = method.aes_gcm.etat
}
}
}Quel que soit le choix, l'état fait partie de ce qu'il faut sauvegarder avec la même rigueur que les VM elles-mêmes, par exemple dans vos sauvegardes chiffrées hors site. Un état perdu n'est pas une catastrophe définitive, terraform import permet de rattacher chaque VM une à une, mais c'est une journée perdue.
Enchaîner avec Ansible pour configurer la VM après sa création
Terraform a livré des machines nues avec un utilisateur et une clé SSH. Tout le reste (paquets, pare-feu, durcissement SSH, services) revient à Ansible, dont le fonctionnement est détaillé dans automatiser la configuration d'un VPS Linux avec Ansible. Le point de jonction est l'inventaire : plutôt que de le maintenir à la main, faites-le écrire par Terraform avec la ressource local_file du provider hashicorp/local, à partir des adresses déjà présentes dans le code. Dans ansible.tf, en déclarant les adresses une seule fois dans un bloc locals réutilisé par les ressources VM et conteneur :
locals {
web01_ip = "192.0.2.11"
db01_ip = "192.0.2.21"
}
resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory.ini"
file_permission = "0644"
content = <<-EOT
[vms]
web01 ansible_host=${local.web01_ip} ansible_user=debian
[lxc]
db01 ansible_host=${local.db01_ip} ansible_user=root
EOT
depends_on = [
proxmox_virtual_environment_vm.web01,
proxmox_virtual_environment_container.db01,
]
}Dans vm.tf et lxc.tf, address devient "${local.web01_ip}/24" et "${local.db01_ip}/24". Le fichier inventory.ini n'existe qu'après apply, et il est réécrit à chaque changement d'adresse. Le premier playbook installe ce qui manquait au template, à commencer par l'agent QEMU dans la VM, puis les réglages de base communs à tout serveur Linux :
---
- name: Agent QEMU dans les machines virtuelles
hosts: vms
become: true
tasks:
- name: Installer qemu-guest-agent
ansible.builtin.apt:
name: qemu-guest-agent
state: present
update_cache: true
- name: Activer et demarrer qemu-guest-agent
ansible.builtin.service:
name: qemu-guest-agent
state: started
enabled: true
- name: Base commune VM et conteneurs
hosts: all
become: true
tasks:
- name: Mettre a jour les paquets
ansible.builtin.apt:
update_cache: true
upgrade: distL'enchaînement complet tient en deux commandes, à lancer depuis le répertoire du projet :
terraform apply
ansible-playbook -i inventory.ini site.ymlUne fois qemu-guest-agent actif dans les VM, passez agent.enabled à true sur web01 et relancez apply : Proxmox affiche l'adresse IP dans son interface, les arrêts propres fonctionnent, stop_on_destroy peut repasser à false, et l'attribut ipv4_addresses se remplit (l'index 0 correspond à l'interface de bouclage, d'où le ipv4_addresses[1][0] des exemples du provider). Poursuivez avec le playbook de durcissement de notre guide Ansible (utilisateur d'administration, UFW, SSH sans mot de passe ni root) : la frontière est nette, Terraform ne touche jamais à l'intérieur d'une machine, Ansible ne crée jamais de machine.
Pièges connus et bonnes pratiques
- Étiquettes triées. Proxmox trie les
tags; une liste non triée dans le code produit une différence perpétuelle à chaque plan. Écrivez-les dans l'ordre alphabétique, ou ajoutezlifecycle { ignore_changes = [tags] }; - Agent QEMU. La cause numéro un des « apply » qui pendent dix minutes :
agent.enabled = truesans agent dans l'invité. Dans le doute, laissezfalseetstop_on_destroy = true; - Identifiants de VM. Fixez
vm_iddans le code (9000 pour les templates, 100 à 199 pour les VM, 200 à 299 pour les conteneurs, par exemple) : un identifiant explicite rend les plans lisibles et évite les collisions avec des invités créés à la main ; - Un projet par nœud ou par cluster, avec son propre état. Le provider gère plusieurs nœuds dans un même projet via
node_name, mais un état unique pour toute une entreprise devient vite un point de contention ; - Le token n'est pas dans le code.
PROXMOX_VE_API_TOKENdans l'environnement,terraform.tfvarsdans.gitignore, et des variables marquéessensitive = truepour les mots de passe éventuels, que Terraform masque alors dans la sortie des plans ; - Le serveur dédié reste à dimensionner. Terraform crée des VM aussi vite que vous écrivez du code ; la RAM et les cœurs de l'hôte, eux, ne suivent pas. Notre guide dimensionner Proxmox VE sur un serveur dédié donne les ordres de grandeur ;
- Mise à jour du provider. Les versions 0.x avancent vite (trois versions entre le 27 octobre et le 2 novembre 2026). Changez l'épingle dans
providers.tf, lancezterraform init -upgradepuis unplan: un plan vide confirme que la mise à jour n'a rien changé à l'interprétation de votre code.
Conclusion
Avec une cinquantaine de lignes de HCL, un utilisateur terraform@pve et un token API, un serveur dédié Proxmox VE devient une plateforme reconstructible : l'image cloud, le template Cloud-Init, chaque VM et chaque conteneur LXC sont décrits dans Git, créés par terraform apply, vérifiés par terraform plan et retirés proprement par terraform destroy. L'état, stocké à l'abri et verrouillé, est la pièce à ne jamais négliger ; Ansible prend le relais à l'intérieur des machines, à partir d'un inventaire que Terraform écrit lui-même. Ce découpage vaut pour un hôte unique comme pour plusieurs clusters supervisés depuis ProxCenter ou surveillés par Pulse. Sur un serveur dédié ElypseCloud bi-processeur Intel Xeon Gold 6230 (40 cœurs, 80 threads) avec 32 à 256 Go de RAM ECC, hébergé en France, le même code déploie en quelques minutes la dizaine de VM et de conteneurs qui prenaient auparavant une journée de clics.
FiveM
Minecraft
Rust
RedM
Hytale