Offre Durée limitée : -10% sur tout le site* avec le code ELYPSEWELCOME10

Proxmox et Terraform : créer ses VM et conteneurs LXC par code sur un serveur dédié

Mis à jour le 8 novembre 2026 24 min de lecture 14 sections

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éthodeReproductibleVersionnable dans GitDétecte une dériveConfigure l'intérieur de la VM
Interface web ProxmoxNon, chaque VM est faite à la mainNonNonNon (console)
Commandes qm et pct dans un script shellOui, tant que le script ne casse pasOuiNon, le script ne sait pas ce qui existeNon
Proxmox VE Helper-ScriptsOui, pour un service donnéPas vos paramètresNonOui, pour le service du script
Terraform + provider bpg/proxmoxOui, à l'identiqueOui, c'est le principeOui, terraform plan affiche la différencePremière configuration seulement, via Cloud-Init
AnsibleOui, idempotentOuiOui, mode --checkOui, 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 sont local (fichiers : ISO, templates, snippets) et local-lvm (disques de VM), son pont réseau est vmbr0 ;
  • 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 version

OpenTofu 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 Terraform

Pour 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 Terraform

Le 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 init

terraform 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 = true et started = 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 que qemu-guest-agent ne 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 du qemu64 par défaut ; memory.floating égal à dedicated active le ballooning ;
  • disk : le disque est importé depuis l'image téléchargée (import_from) sur local-lvm, en interface scsi0, avec iothread et discard comme 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_account crée l'utilisateur debian avec votre clé publique, ip_config demande une adresse en DHCP. Le provider attache ce lecteur en ide2 par 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émentVM : proxmox_virtual_environment_vmLXC : proxmox_virtual_environment_container
SourceImage cloud qcow2 (content_type = "import") ou clone d'un templateTemplate vztmpl (.tar.zst)
DisqueBloc disk : import_from, interface, size (8 Go par défaut)Bloc disk : size (4 Go par défaut)
Réseaunetwork_device : bridge, vlan_id, modèle virtionetwork_interface : name obligatoire, bridge (vmbr0 par défaut), vlan_id
Compte et clé SSHinitialization.user_account (Cloud-Init) ou user_data_file_idinitialization.user_account : clés et mot de passe du compte root
Adresse IPinitialization.ip_configinitialization.ip_config
Agent invitéBloc agent, qemu-guest-agent dans l'invitéSans objet
Démarragestarted, on_boot, stop_on_destroystarted, 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.tfplan

terraform 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.tfplan

Le 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 :

CommandeEffet
terraform planAffiche la différence entre le code, l'état et la réalité, sans rien modifier
terraform applyApplique la différence après confirmation (ou sans, avec un plan enregistré)
terraform state listListe les ressources connues de l'état
terraform showAffiche l'état complet, attributs exportés compris
terraform destroy -target=proxmox_virtual_environment_vm.web01Détruit une seule ressource
terraform destroyDétruit tout ce que ce projet a créé, dans l'ordre inverse des dépendances
terraform importRattache à 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'étatPour quiVerrouillageRemarques
Fichier local (par défaut)Un seul administrateur, un seul posteVerrou localÀ exclure de Git, à sauvegarder chiffré hors site
Backend s3 sur un stockage compatible S3 (MinIO, Garage, service objet)Plusieurs postes ou une CIuse_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 baseNatifUne base à sauvegarder, pas de bucket
OpenTofu : n'importe lequel + encryptionTousSelon 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: dist

L'enchaînement complet tient en deux commandes, à lancer depuis le répertoire du projet :

terraform apply
ansible-playbook -i inventory.ini site.yml

Une 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 ajoutez lifecycle { ignore_changes = [tags] } ;
  • Agent QEMU. La cause numéro un des « apply » qui pendent dix minutes : agent.enabled = true sans agent dans l'invité. Dans le doute, laissez false et stop_on_destroy = true ;
  • Identifiants de VM. Fixez vm_id dans 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_TOKEN dans l'environnement, terraform.tfvars dans .gitignore, et des variables marquées sensitive = true pour 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, lancez terraform init -upgrade puis un plan : 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.

FAQ

Faut-il Terraform ou OpenTofu pour piloter Proxmox VE ?

Les deux fonctionnent avec le provider bpg/proxmox, qui demande Terraform 1.6 ou plus récent, ou OpenTofu 1.6 ou plus récent. Les fichiers HCL, le provider et l'état sont les mêmes ; seule la commande change (terraform ou tofu). OpenTofu, fork communautaire sous l'égide de la Linux Foundation, ajoute le chiffrement de l'état au repos depuis sa version 1.7, ce que Terraform ne propose pas nativement.

Le provider bpg/proxmox fonctionne-t-il avec Proxmox VE 8 ?

Oui, avec des réserves. La documentation du provider cible Proxmox VE 9.x, indique que la 8.x fonctionne avec quelques limitations sans être une priorité de test, et exclut la 7.x. Un cas concret : avant Proxmox VE 8.4, le nœud refuse les fichiers téléchargés avec une extension .qcow2 ou .raw ; il faut alors le type de contenu iso et un nom de fichier terminé par .img.

Terraform a-t-il besoin d'un accès SSH au nœud Proxmox ?

Non pour l'essentiel : création, modification et suppression des VM et des conteneurs, stockage, réseaux, pools, import de disque par import_from et téléchargement d'images passent par l'API et le token. SSH ne sert qu'à déposer des snippets (Cloud-Init personnalisé), importer un disque depuis un chemin local du nœud ou configurer les idmap d'un conteneur. Le bloc ssh doit néanmoins nommer un utilisateur quand on s'authentifie par token ; il n'est utilisé que si l'une de ces opérations est demandée, avec une règle sudo minimale sur pvesm apiinfo.

Pourquoi la création de ma VM met-elle dix minutes, ou pourquoi refuse-t-elle de s'arrêter ?

Presque toujours parce que agent.enabled vaut true alors que qemu-guest-agent ne tourne pas dans l'invité. Le provider attend l'agent à la création et au rafraîchissement, et les arrêts ou redémarrages échouent en verrouillant la VM. Laissez agent.enabled à false et stop_on_destroy à true tant que l'agent n'est pas installé, par exemple par un playbook Ansible, puis activez-le.

Où stocker le fichier d'état terraform.tfstate ?

Jamais dans Git : il contient en clair tout ce que le provider a lu ou écrit, mots de passe Cloud-Init compris. Pour un administrateur seul, un fichier local sauvegardé chiffré hors site suffit. À plusieurs ou en CI, un backend distant verrouillé : s3 sur un stockage compatible S3 avec use_lockfile = true (verrou natif depuis Terraform 1.10, DynamoDB déprécié), ou pg sur PostgreSQL. Avec OpenTofu, le bloc encryption chiffre l'état quel que soit le backend.

Terraform remplace-t-il Ansible sur un serveur Proxmox ?

Non, ils se complètent. Terraform crée, modifie et détruit les ressources de l'hyperviseur (images, templates, VM, conteneurs) et en garde la trace dans son état. Ansible se connecte ensuite en SSH aux machines créées pour installer les paquets, durcir SSH, configurer le pare-feu et déployer les services. Le lien entre les deux est l'inventaire, que Terraform peut écrire lui-même avec la ressource local_file à partir des adresses déclarées dans le code.

Peut-on reprendre avec Terraform une VM créée à la main dans l'interface Proxmox ?

Oui, avec terraform import : on écrit d'abord la ressource proxmox_virtual_environment_vm qui décrit la VM existante, puis on rattache l'objet réel à l'état avec son nœud et son identifiant. Un terraform plan vide confirme que le code et la réalité coïncident ; à partir de là, la VM se gère par code comme les autres.