Automatiser Proxmox avec Terraform et Cloud-Init
Découvrez comment automatiser le déploiement de machines virtuelles Proxmox avec Terraform et Cloud-Init, des modèles Debian aux opérations quotidiennes.
Pourquoi j’ai créé ce flux
J’ai fini par atteindre le point où l’interface de Proxmox me ralentissait plus qu’elle ne m’aidait. J’avais besoin que tout soit défini en code afin de pouvoir examiner les changements, d’un labo qui se reconstruise toujours de la même manière et de machines prêtes pour SSH en quelques minutes plutôt qu’en une demi-heure.
Voici le flux de travail que j’utilise dans mon homelab. Il est volontairement simple et reproductible : un modèle de base, Terraform pour le cloner et Cloud-Init pour terminer la configuration initiale.
Le flux en bref
- Créer un modèle Debian avec Cloud-Init installé.
- Le cloner en un modèle adapté à Terraform (sans lecteur Cloud-Init attaché).
- Terraform clone le modèle et attache la configuration Cloud-Init.
- La VM démarre et est prête pour SSH.
C’est tout. Tous ces clics sont maintenant du code.
Prérequis
- Un nœud Proxmox auquel vous pouvez vous connecter en SSH
- Un stockage de modèles de VM (j’utilise
vmdata) - Un stockage
snippets(j’utiliselocal) - Terraform installé localement
Étape 1 : utilisateur de l’API Proxmox (Terraform)
J’utilise un utilisateur Proxmox et un jeton dédiés plutôt que mon compte personnel. Cela permet de limiter les accès et de clarifier les journaux.
1
2
3
4
5
6
7
# Create API token for Terraform
pveum user add terraform@pve
sudo 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"
sudo pveum aclmod / -user terraform@pve -role Terraform
sudo pveum user token add terraform@pve provider --privsep=0
# Note the token - you'll need it for Terraform
Si votre politique RBAC est plus stricte, réduisez les privilèges. J’ai choisi une liste étendue pour éviter les mauvaises surprises.
Étape 2 : créer un snippet Cloud-Init
Les snippets sont de petits fichiers YAML que Proxmox peut attacher comme données Cloud-Init. Je conserve un snippet de base à l’emplacement suivant :
/var/lib/vz/snippets/base_vm.yaml
1
2
3
4
5
6
7
#cloud-config
packages:
- qemu-guest-agent
package_update: true
power_state:
mode: reboot
timeout: 30
Cette configuration est volontairement minimale. Je place la plupart des personnalisations dans les variables Terraform.
Étape 3 : créer le modèle Debian
J’utilise un petit script pour créer deux modèles :
9110inclut un lecteur Cloud-Init pour une utilisation directe avec Proxmox9100supprime le lecteur Cloud-Init afin de permettre le clonage avec Terraform
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
#!/bin/bash
# Creates two Debian 13 templates:
# - 9110 with cloudinit drive (for direct Proxmox use)
# - 9100 without cloudinit drive (for Terraform)
if [ "$EUID" -ne 0 ]; then
echo "Run as root"
exit 1
fi
VMID_WITH=9110
VMID_WITHOUT=9100
MEMORY=2048
BRIDGE="vmbr2"
STORAGE="vmdata"
SSH_KEY="$HOME/.ssh/authorized_keys"
echo "Downloading Debian 13 image..."
wget -q --show-progress https://cloud.debian.org/images/cloud/trixie/latest/debian-13-generic-amd64.qcow2
# Create base template with cloudinit
qm create $VMID_WITH --name debian13-cloud --memory $MEMORY --net0 virtio,bridge=$BRIDGE
qm importdisk $VMID_WITH debian-13-generic-amd64.qcow2 $STORAGE -format qcow2
qm set $VMID_WITH --scsihw virtio-scsi-pci --scsi0 $STORAGE:vm-$VMID_WITH-disk-0
qm set $VMID_WITH --ide2 $STORAGE:cloudinit --boot c --bootdisk scsi0 --serial0 socket --vga serial0
qm resize $VMID_WITH scsi0 +20G
qm set $VMID_WITH --ipconfig0 ip=10.0.100.10/24,gw=10.0.100.1
if [ -f "$SSH_KEY" ]; then
qm set $VMID_WITH --sshkey $SSH_KEY
fi
qm template $VMID_WITH
echo "Created template $VMID_WITH with cloudinit"
# Clone for Terraform (no cloudinit drive)
qm clone $VMID_WITH $VMID_WITHOUT --name debian13-cloud-template --full
qm set $VMID_WITHOUT --delete ide2
qm template $VMID_WITHOUT
echo "Created template $VMID_WITHOUT without cloudinit (for Terraform)"
rm debian-13-generic-amd64.qcow2
echo "Done"
Si vous avez besoin de nombreux modèles de systèmes d’exploitation différents, envisagez Packer. Pour quelques modèles, ce script suffit.
Étape 4 : configurer le fournisseur Terraform
main.tf :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
terraform {
required_version = ">= 1.0"
required_providers {
proxmox = {
source = "bpg/proxmox"
version = "0.86"
}
local = {
source = "hashicorp/local"
version = ">= 2.0"
}
}
}
provider "proxmox" {
endpoint = var.proxmox.endpoint
api_token = var.proxmox.api_token
insecure = var.proxmox.insecure
}
Je verrouille les versions pour que les mises à niveau soient explicites.
Étape 5 : définition de la ressource VM
Voici le cœur de la configuration : cloner le modèle, attacher Cloud-Init, configurer le réseau et démarrer la machine.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
resource "proxmox_virtual_environment_vm" "vm" {
for_each = { for vm in var.vms : vm.name => vm }
name = each.value.name
node_name = var.proxmox.node_name
description = "Debian 13 VM"
clone {
vm_id = var.proxmox.template_id
full = true
datastore_id = var.datastore_id
retries = var.clone_retries
}
agent {
enabled = true
timeout = var.agent_timeout
}
cpu {
cores = each.value.cpu_cores
}
memory {
dedicated = each.value.memory_mb
}
disk {
datastore_id = var.datastore_id
size = coalesce(each.value.disk_gb, var.disk_size_gb)
interface = var.disk_interface
iothread = true
discard = "on"
}
network_device {
bridge = each.value.bridge
vlan_id = var.vlan_id
}
initialization {
interface = var.cloud_init_interface
type = "nocloud"
vendor_data_file_id = var.vendor_data_file_id
user_account {
username = var.cloud_init_username
password = var.cloud_init_password != "" ? var.cloud_init_password : null
keys = [trimspace(data.local_file.ssh_pub.content)]
}
ip_config {
ipv4 {
address = each.value.ip_address
gateway = split("/", each.value.ip_address)[0] != each.value.ip_address ? cidrhost(each.value.ip_address, 1) : null
}
}
dns {
servers = var.dns_servers
}
}
lifecycle {
ignore_changes = [initialization["user_account"]]
}
started = true
tags = concat(var.tags, ["debian13"])
}
Remarques :
vendor_data_file_idest l’identifiant de fichier Proxmox d’un snippet (voir l’exemple de l’étape 7).cloud_init_interfaceest le nom de l’interface du système d’exploitation que Cloud-Init configure. Sur la plupart des images Debian de Proxmox, il s’agit deens18, mais certaines images utilisent encoreeth0.- La logique de passerelle suppose que la première adresse hôte du sous-réseau est la passerelle (pour
10.0.100.50/24, elle devient10.0.100.1). Si votre passerelle est différente, indiquez-la explicitement dans la configuration de chaque VM et utilisez cette valeur directement.
Étape 6 : variables principales
Connexion à Proxmox :
proxmox.endpoint:https://proxmox.example.com:8006/proxmox.api_token:user@realm!token=secretproxmox.insecure:truepour les certificats auto-signésproxmox.node_name: nœud Proxmox cible
Cloud-Init :
cloud_init_username: utilisateur par défaut à créercloud_init_password: mot de passe facultatif (je le laisse généralement vide)vendor_data_file_id: identifiant du fichier snippet (par exemplelocal:snippets/base_vm.yaml)dns_servers: serveurs DNS de la VM
Liste des VM :
vms: tableau des configurations de chaque VM (nom, processeur, mémoire, IP, pont réseau)
Étape 7 : variables et sorties (exemple minimal fonctionnel)
Ces snippets rendent les exemples précédents exécutables sans devoir deviner les valeurs. Remplacez les espaces réservés pour les adapter à votre labo.
variables.tf :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
variable "proxmox" {
type = object({
endpoint = string
api_token = string
insecure = bool
node_name = string
template_id = number
})
}
variable "datastore_id" {
type = string
default = "vmdata"
}
variable "clone_retries" {
type = number
default = 3
}
variable "agent_timeout" {
type = number
default = 60
}
variable "disk_size_gb" {
type = number
default = 20
}
variable "disk_interface" {
type = string
default = "scsi0"
}
variable "vlan_id" {
type = number
default = null
}
variable "tags" {
type = list(string)
default = []
}
variable "cloud_init_interface" {
type = string
default = "ens18"
}
variable "dns_servers" {
type = list(string)
default = ["10.0.100.1"]
}
variable "cloud_init_username" {
type = string
default = "alex"
}
variable "cloud_init_password" {
type = string
default = ""
}
variable "vendor_data_file_id" {
type = string
}
variable "vms" {
type = list(object({
name = string
cpu_cores = number
memory_mb = number
disk_gb = optional(number)
ip_address = string
bridge = string
}))
}
terraform.tfvars :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
proxmox = {
endpoint = "https://proxmox.example.com:8006/"
api_token = "terraform@pve!provider=YOUR_TOKEN"
insecure = true
node_name = "pve01"
template_id = 9100
}
datastore_id = "vmdata"
cloud_init_interface = "ens18"
dns_servers = ["10.0.100.1"]
cloud_init_username = "alex"
vendor_data_file_id = "local:snippets/base_vm.yaml"
vms = [
{
name = "debian13-01"
cpu_cores = 2
memory_mb = 2048
disk_gb = 20
ip_address = "10.0.100.20/24"
bridge = "vmbr0"
}
]
outputs.tf :
1
2
3
4
5
6
output "ssh_commands" {
value = [
for vm in var.vms :
"ssh ${var.cloud_init_username}@${split(\"/\", vm.ip_address)[0]}"
]
}
Source de données data pour votre clé publique SSH (référencée dans la ressource VM) :
1
2
3
data "local_file" "ssh_pub" {
filename = pathexpand("~/.ssh/id_ed25519.pub")
}
Remarques :
vmdata,local,vmbr2etvmbr0sont des noms propres à mon labo. Utilisez les noms de stockage et de pont réseau de votre environnement Proxmox.vendor_data_file_idattend l’identifiant du snippet Proxmox au formatstorage:snippets/file.yaml. Si vous téléversez les snippets avec Terraform, utilisez la valeur.iddeproxmox_virtual_environment_file.
Opérations quotidiennes
Déploiement :
1
2
3
4
terraform init
terraform plan
terraform apply
terraform output ssh_commands
Remarque : -target est utile pour des changements ponctuels, mais n’en faites pas votre méthode habituelle. Cette option peut ignorer des dépendances et masquer une dérive de configuration. Une fois à l’aise, un terraform apply normal est plus sûr.
Inspection :
1
2
3
4
5
terraform show
terraform state list
terraform state show proxmox_virtual_environment_vm.vm["debian13-01"]
pvesh get /nodes/pve01/qemu --output-format json | jq
Modèles courants que j’utilise
Parc de développement et de test rapide
1
2
3
4
5
6
7
8
9
10
11
12
13
14
locals {
dev_vms = [
for i in range(1, 6) : {
name = "dev-${format("%02d", i)}"
cpu_cores = 2
memory_mb = 2048
disk_gb = 20
ip_address = "10.0.100.${20 + i}/24"
bridge = "vmbr0"
}
]
}
vms = local.dev_vms
Cluster Kubernetes
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
vms = concat(
[for i in range(1, 4) : {
name = "k8s-master-${format("%02d", i)}"
cpu_cores = 4
memory_mb = 8192
disk_gb = 50
ip_address = "10.0.100.${10 + i}/24"
bridge = "vmbr0"
}],
[for i in range(1, 6) : {
name = "k8s-worker-${format("%02d", i)}"
cpu_cores = 8
memory_mb = 16384
disk_gb = 100
ip_address = "10.0.100.${20 + i}/24"
bridge = "vmbr0"
}]
)
Optimisation SSD
Activez TRIM dans Terraform :
1
2
3
4
disk {
discard = "on"
ssd = true
}
Et planifiez fstrim avec Cloud-Init :
1
2
3
#cloud-config
runcmd:
- echo "0 2 * * 0 root /usr/sbin/fstrim -av" >> /etc/crontab
Notes de sécurité
- Utilisez un utilisateur Proxmox et un jeton d’API dédiés.
- Ne commitez jamais de jetons ni de mots de passe.
- Désactivez la connexion SSH de
rootdans votre modèle. - Mettez à jour le modèle avant le clonage afin que les nouvelles VM soient corrigées au premier démarrage.
Cloud-Init et VyOS (avancé)
VyOS ne prend pas en charge les clés Cloud-Init standard telles que users ou packages. Il accepte uniquement :
vyos_config_commandswrite_files
Exemple de snippet :
1
2
3
4
5
6
7
8
9
10
11
12
#cloud-config
vyos_config_commands:
- set system host-name 'vyos-router'
- set system time-zone 'UTC'
- set system login user ansible authentication public-keys key-01 key 'AAAAB3NzaC...'
- set system login user ansible authentication public-keys key-01 type 'ssh-rsa'
- set interfaces ethernet eth0 address '192.168.1.1/24'
- set interfaces ethernet eth0 description 'WAN'
- set protocols static route 0.0.0.0/0 next-hop '192.168.1.254'
- set service ssh port '22'
- commit
- save
Téléverser des snippets avec Terraform
Au lieu de placer manuellement les fichiers snippet sur l’hôte Proxmox, vous pouvez les téléverser avec Terraform :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resource "proxmox_virtual_environment_file" "vyos_userdata" {
for_each = { for vm in var.vyos_vms : vm.name => vm }
datastore_id = "local"
node_name = "pve01"
content_type = "snippets"
source_raw {
file_name = "cloud-init-${each.key}.yml"
data = templatefile("${path.module}/cloud-init-vyos.tpl.yml", {
hostname = each.value.hostname
wan_ip = each.value.wan_ip
wan_gateway = each.value.wan_gateway
timezone = var.timezone
ssh_keys = var.ssh_keys
})
}
}
Référencez ensuite le snippet dans la ressource de votre VM :
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
resource "proxmox_virtual_environment_vm" "vyos" {
for_each = { for vm in var.vyos_vms : vm.name => vm }
name = each.value.name
node_name = var.proxmox.node_name
clone {
vm_id = var.vyos_template_id
full = true
}
initialization {
type = "nocloud"
datastore_id = "local"
user_data_file_id = proxmox_virtual_environment_file.vyos_userdata[each.key].id
}
}
Des exemples supplémentaires sont disponibles pour une configuration complète de déploiement VyOS avec pare-feu basé sur des zones.
Liste de vérification pour le dépannage
1
2
3
4
5
6
7
# Test Proxmox API reachability
curl -k -H "Authorization: PVEAPIToken=terraform@pve!terraform=your-token" \
https://proxmox.example.com:8006/api2/json/nodes
# Cloud-Init logs inside a VM
sudo cat /var/log/cloud-init.log
sudo cloud-init status --long
Si ces vérifications réussissent, le problème vient généralement d’une faute de frappe dans les variables ou d’une référence de snippet manquante. Un nouveau terraform plan permet généralement de le repérer facilement.




