Post

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.

Automatiser Proxmox avec Terraform et Cloud-Init

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

  1. Créer un modèle Debian avec Cloud-Init installé.
  2. Le cloner en un modèle adapté à Terraform (sans lecteur Cloud-Init attaché).
  3. Terraform clone le modèle et attache la configuration Cloud-Init.
  4. 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’utilise local)
  • 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.

Capture d’écran des permissions de l’API Proxmox

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

Capture d’écran des snippets Proxmox

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 :

  • 9110 inclut un lecteur Cloud-Init pour une utilisation directe avec Proxmox
  • 9100 supprime le lecteur Cloud-Init afin de permettre le clonage avec Terraform

Capture d’écran du modèle Proxmox

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.

Capture d’écran de Proxmox Cloud-Init

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_id est l’identifiant de fichier Proxmox d’un snippet (voir l’exemple de l’étape 7).
  • cloud_init_interface est 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 de ens18, mais certaines images utilisent encore eth0.
  • 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 devient 10.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=secret
  • proxmox.insecure : true pour les certificats auto-signés
  • proxmox.node_name : nœud Proxmox cible

Cloud-Init :

  • cloud_init_username : utilisateur par défaut à créer
  • cloud_init_password : mot de passe facultatif (je le laisse généralement vide)
  • vendor_data_file_id : identifiant du fichier snippet (par exemple local: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, vmbr2 et vmbr0 sont des noms propres à mon labo. Utilisez les noms de stockage et de pont réseau de votre environnement Proxmox.
  • vendor_data_file_id attend l’identifiant du snippet Proxmox au format storage:snippets/file.yaml. Si vous téléversez les snippets avec Terraform, utilisez la valeur .id de proxmox_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 root dans 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_commands
  • write_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.

Cet article est sous licence CC BY 4.0 par l'auteur.