Aller au contenu
Infrastructure as Code medium
🔐 Alerte sécurité — Incident supply chain Trivy : lire mon analyse de l'attaque

Provisionner avec Ansible depuis Terraform

20 min de lecture

logo terraform

Terraform crée l’infrastructure, Ansible la configure. En combinant les deux, vous déployez une VM et installez vos logiciels en un seul workflow, sans inventaire statique à maintenir. Ce guide vous montre comment déclarer vos serveurs dans le code Terraform et laisser Ansible les découvrir automatiquement grâce à l’inventaire dynamique.

La séparation entre provisionnement et configuration est un pattern fondamental en DevOps : Terraform gère le “quoi” (quelles machines, quels réseaux), Ansible gère le “comment” (quels paquets, quelle configuration). Le provider ansible fait le lien entre les deux outils sans fichier intermédiaire, sans script de glue.

Prérequis : Terraform ≥ 1.11 installé, KVM/libvirt opérationnel, Ansible installé avec la collection cloud.terraform.

  • Déclarer un inventaire Ansible dans Terraform : groupes et hôtes via le provider ansible/ansible
  • Configurer l’inventaire dynamique : le plugin cloud.terraform.terraform_provider lit le state Terraform
  • Enchaîner Terraform et Ansible : créer la VM, puis exécuter un playbook qui l’installe automatiquement
  • Appliquer le pattern complet : VM + cloud-init + réseau + provisionnement logiciel

Quand vous créez une VM avec Terraform, elle démarre avec un système vierge. Cloud-init peut installer quelques paquets au premier boot, mais ses capacités restent limitées : pas de gestion de templates, pas de handlers, pas d’idempotence avancée.

Ansible excelle précisément là où cloud-init s’arrête : configuration fine des services, déploiement d’applications, gestion des redémarrages conditionnels. Le problème classique : comment Ansible sait-il quelles machines cibler ?

Traditionnellement, il fallait maintenir un fichier d’inventaire statique (hosts.ini) à la main, ou écrire un script qui interroge l’hyperviseur. Le provider ansible élimine cette étape : les groupes et hôtes sont déclarés dans le code Terraform, et un plugin d’inventaire les lit directement depuis le state.

Le provider ansible/ansible ajoute deux types de ressources à Terraform :

RessourceRôle
ansible_groupDéclare un groupe d’inventaire (ex : webservers, databases)
ansible_hostDéclare un hôte avec ses variables de connexion (IP, utilisateur, clé SSH)

Ces ressources ne créent rien sur l’infrastructure : elles enregistrent des informations dans le state Terraform. Le plugin d’inventaire cloud.terraform.terraform_provider lit ensuite ce state pour générer un inventaire Ansible complet.

Avant de commencer, vérifiez que la collection Ansible cloud.terraform est installée. C’est elle qui fournit le plugin d’inventaire dynamique :

Fenêtre de terminal
ansible-galaxy collection install cloud.terraform

Vérification :

Fenêtre de terminal
ansible-galaxy collection list | grep cloud.terraform

Le résultat doit afficher la collection avec sa version (≥ 4.0.0).

Créez un répertoire dédié avec la structure suivante :

~/terraform-ansible/
├── versions.tf # Providers requis
├── variables.tf # Variables d'entrée
├── cloud-init.cfg # Configuration premier boot
├── network-config.cfg # Réseau statique pour cloud-init
├── main.tf # VM + inventaire Ansible
├── outputs.tf # Sorties
├── inventory.yml # Plugin inventaire dynamique
└── playbook.yml # Playbook Ansible

Chaque fichier a un rôle précis. Les sections suivantes les détaillent un par un.

Le fichier versions.tf déclare les deux providers nécessaires : libvirt pour créer la VM et ansible pour gérer l’inventaire :

versions.tf
terraform {
required_version = ">= 1.11.0"
required_providers {
libvirt = {
source = "dmacvicar/libvirt"
version = "~> 0.8"
}
ansible = {
source = "ansible/ansible"
version = "~> 1.3"
}
}
}
provider "libvirt" {
uri = "qemu:///system"
}

Le provider ansible n’a pas besoin de bloc provider {} : il ne se connecte à aucun service externe. Il écrit uniquement dans le state local.

Le fichier variables.tf centralise les paramètres modifiables. Adaptez base_image au chemin de votre image Ubuntu cloud :

variables.tf
variable "vm_name" {
description = "Nom de la VM"
type = string
default = "ansible-vm"
}
variable "base_image" {
description = "Chemin de l'image Ubuntu cloud (qcow2)"
type = string
default = "/chemin/vers/ubuntu-24.04-cloudimg.img"
}
variable "ssh_public_key" {
description = "Chemin de la clé publique SSH"
type = string
default = "~/.ssh/id_ed25519.pub"
}
variable "ssh_private_key" {
description = "Chemin de la clé privée SSH"
type = string
default = "~/.ssh/id_ed25519"
}

Cloud-init s’exécute au premier démarrage de la VM. Le fichier cloud-init.cfg crée un utilisateur avec votre clé SSH et installe le qemu-guest-agent (nécessaire pour que libvirt détecte l’IP de la VM) :

#cloud-config
users:
- name: ubuntu
sudo: ALL=(ALL) NOPASSWD:ALL
groups: sudo
shell: /bin/bash
ssh_authorized_keys:
- ${ssh_public_key}
package_update: true
packages:
- qemu-guest-agent
runcmd:
- systemctl enable --now qemu-guest-agent

La variable ${ssh_public_key} sera remplacée par Terraform grâce à la fonction templatefile().

Le fichier network-config.cfg configure une IP statique via netplan (format cloud-init v2). Adaptez l’adresse à votre réseau libvirt :

version: 2
ethernets:
enp1s0:
dhcp4: false
addresses:
- 192.168.122.200/24
routes:
- to: default
via: 192.168.122.1
nameservers:
addresses:
- 192.168.122.1
- 8.8.8.8

Le fichier main.tf contient quatre types de ressources. Voici la logique avant le code :

  1. Un volume disque : copie de l’image Ubuntu dans le pool libvirt
  2. Une ISO cloud-init : contient la configuration utilisateur + réseau
  3. Une VM : assemblage du disque, du cloud-init et du réseau
  4. Un inventaire Ansible : groupe webservers + hôte avec les variables de connexion
main.tf
# ── Volume disque ─────────────────────────────────────
resource "libvirt_volume" "disk" {
name = "${var.vm_name}.qcow2" # Nom du fichier dans le pool
pool = "default" # Pool de stockage libvirt
target = { format = { type = "qcow2" } }
create = { content = { url = var.base_image } } # Copie l'image de base
}
# ── Cloud-init ISO ────────────────────────────────────
resource "libvirt_cloudinit_disk" "init" {
name = "${var.vm_name}-cloud-init.iso"
# Configuration utilisateur (clé SSH, paquets)
user_data = templatefile("${path.module}/cloud-init.cfg", {
ssh_public_key = trimspace(file(pathexpand(var.ssh_public_key)))
})
# Configuration réseau (IP statique)
network_config = file("${path.module}/network-config.cfg")
# Métadonnées obligatoires
meta_data = jsonencode({
instance-id = var.vm_name
local-hostname = var.vm_name
})
}
# ── VM ────────────────────────────────────────────────
resource "libvirt_domain" "vm" {
name = var.vm_name
type = "kvm"
memory = 1024 # 1 Go de RAM
memory_unit = "MiB"
vcpu = 1
running = true # Démarrer la VM immédiatement
os = { type = "hvm", type_arch = "x86_64", type_machine = "q35" }
devices = {
disks = [
{
driver = { name = "qemu", type = "qcow2" } # Format du disque
source = { file = { file = libvirt_volume.disk.path } }
target = { dev = "vda", bus = "virtio" } # Disque principal
},
{
device = "cdrom" # Cloud-init en CDROM
driver = { name = "qemu", type = "raw" }
source = { file = { file = libvirt_cloudinit_disk.init.path } }
target = { dev = "sda", bus = "sata" }
read_only = true
},
]
interfaces = [{
model = { type = "virtio" }
source = { network = { network = "default" } } # Réseau libvirt
}]
}
}
# ── Inventaire Ansible ────────────────────────────────
resource "ansible_group" "webservers" {
name = "webservers" # Nom du groupe dans l'inventaire
}
resource "ansible_host" "vm" {
name = var.vm_name # Nom de l'hôte
groups = [ansible_group.webservers.name] # Appartenance au groupe
variables = {
ansible_host = "192.168.122.200" # IP de la VM
ansible_user = "ubuntu" # Utilisateur SSH
ansible_ssh_private_key_file = pathexpand(var.ssh_private_key)
ansible_ssh_common_args = "-o StrictHostKeyChecking=no"
}
}

Le fichier outputs.tf affiche les informations utiles après le terraform apply :

outputs.tf
output "vm_name" {
description = "Nom de la VM"
value = libvirt_domain.vm.name
}
output "ansible_group" {
description = "Groupe Ansible"
value = ansible_group.webservers.name
}
output "ansible_host" {
description = "Hôte Ansible"
value = ansible_host.vm.name
}

Le fichier inventory.yml est le point d’entrée d’Ansible. Une seule ligne suffit pour activer le plugin d’inventaire :

---
plugin: cloud.terraform.terraform_provider

Ce plugin lit le fichier terraform.tfstate dans le répertoire courant et génère automatiquement un inventaire à partir des ressources ansible_group et ansible_host.

Le playbook playbook.yml installe nginx sur tous les serveurs du groupe webservers et vérifie qu’il répond :

---
- name: Configurer le serveur web
hosts: webservers
become: true
tasks:
- name: Installer nginx
ansible.builtin.apt:
update_cache: true
name: nginx
state: present
- name: Activer et démarrer nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Vérifier que nginx répond
ansible.builtin.uri:
url: "http://localhost"
status_code: 200
register: result
- name: Afficher le résultat
ansible.builtin.debug:
msg: "nginx répond avec le code {{ result.status }}"
  1. Initialisez Terraform pour télécharger les providers libvirt et ansible :

    Fenêtre de terminal
    cd ~/terraform-ansible
    terraform init
  2. Validez la configuration pour détecter les erreurs de syntaxe :

    Fenêtre de terminal
    terraform validate

    Le résultat attendu est Success! The configuration is valid.

  3. Appliquez pour créer la VM et enregistrer l’inventaire :

    Fenêtre de terminal
    terraform apply -auto-approve

    Terraform crée 5 ressources :

    Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
    Outputs:
    ansible_group = "webservers"
    ansible_host = "ansible-vm"
    vm_name = "ansible-vm"
  4. Attendez que la VM soit prête (cloud-init prend 30 à 60 secondes) :

Lancez ssh -o StrictHostKeyChecking=no -i ~/.ssh/id_ed25519 ubuntu@192.168.122.200 "hostname".

Quand SSH répond ansible-vm, la VM est prête.

  1. Vérifiez l’inventaire dynamique généré par Terraform :

    Fenêtre de terminal
    ansible-inventory -i inventory.yml --list

    Le résultat montre le groupe webservers avec l’hôte ansible-vm et ses variables de connexion :

    {
    "webservers": {
    "hosts": ["ansible-vm"]
    },
    "_meta": {
    "hostvars": {
    "ansible-vm": {
    "ansible_host": "192.168.122.200",
    "ansible_user": "ubuntu",
    "ansible_ssh_private_key_file": "/home/user/.ssh/id_ed25519",
    "ansible_ssh_common_args": "-o StrictHostKeyChecking=no"
    }
    }
    }
    }
  2. Exécutez le playbook pour installer nginx :

    Fenêtre de terminal
    ansible-playbook playbook.yml -i inventory.yml

    Le résultat attendu :

    PLAY [Configurer le serveur web] *********************************************
    TASK [Gathering Facts] *******************************************************
    ok: [ansible-vm]
    TASK [Installer nginx] *******************************************************
    changed: [ansible-vm]
    TASK [Activer et démarrer nginx] *********************************************
    ok: [ansible-vm]
    TASK [Vérifier que nginx répond] *********************************************
    ok: [ansible-vm]
    TASK [Afficher le résultat] **************************************************
    ok: [ansible-vm] => {
    "msg": "nginx répond avec le code 200"
    }
    PLAY RECAP *******************************************************************
    ansible-vm : ok=5 changed=1 unreachable=0 failed=0

Le diagramme ci-dessous résume le flux de données entre Terraform et Ansible :

terraform apply
├──→ libvirt_volume ──→ disque qcow2
├──→ libvirt_cloudinit ──→ ISO cloud-init
├──→ libvirt_domain ──→ VM démarrée
├──→ ansible_group ──→ state (groupe "webservers")
└──→ ansible_host ──→ state (hôte "ansible-vm")
terraform.tfstate
inventory.yml (plugin)
ansible-playbook
nginx installé sur la VM

Les ressources ansible_group et ansible_host n’existent que dans le state — elles ne créent rien sur l’infrastructure. Le plugin d’inventaire cloud.terraform.terraform_provider lit ce state et génère un inventaire Ansible standard.

SymptômeCause probableSolution
ssh: connect to host ... Connection refusedCloud-init n’a pas finiAttendre 30-60 secondes, réessayer
ansible-inventory ne trouve aucun hôtePas de terraform.tfstate dans le répertoireVérifier que terraform apply a été exécuté dans le même répertoire
No module named 'cloud.terraform'Collection Ansible manquanteansible-galaxy collection install cloud.terraform
Playbook échoue sur apt updatePas de réseau dans la VMVérifier network-config.cfg et l’IP gateway
UNREACHABLE dans AnsibleMauvaise clé SSH ou mauvais userVérifier ssh_public_key dans variables.tf et ansible_user dans main.tf

Pour supprimer l’intégralité des ressources créées :

Fenêtre de terminal
terraform destroy -auto-approve

Terraform détruit les 5 ressources dans l’ordre inverse de création. La VM, le disque, l’ISO cloud-init et les entrées d’inventaire sont supprimés.

  • Le provider ansible/ansible ajoute des ressources ansible_group et ansible_host au state Terraform — il ne crée rien sur l’infrastructure
  • Le plugin cloud.terraform.terraform_provider génère l’inventaire Ansible en lisant le state, sans fichier statique
  • Cloud-init configure la VM au premier boot (utilisateur SSH, paquets de base, réseau)
  • Le workflow standard est : terraform apply → attendre SSH → ansible-playbook
  • L’IP de la VM est définie dans network-config.cfg et dans ansible_host.variables — elles doivent correspondre
  • Ce pattern sépare le provisionnement (Terraform) de la configuration (Ansible), chaque outil fait ce qu’il sait le mieux

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens +700 guides gratuits, sans pub ni tracing. Aujourd'hui, ce site ne couvre même pas mes frais d'hébergement, d'électricité, de matériel, de logiciels, mais surtout de cafés.

Un soutien régulier, même symbolique, m'aide à garder ces ressources gratuites et à continuer de produire des guides de qualité. Merci pour votre appui.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn