Le premier projet Terraform, on l'écrit d'un bloc. Le deuxième, on copie-colle une partie du premier. Au troisième, on se rend compte qu'on a trois versions légèrement différentes du même code, et qu'une correction faite dans l'une n'a jamais été reportée dans les autres. C'est le moment où l'on découvre les modules.
Plutôt que de rester dans la théorie, j'ai choisi pour ce cours un exemple concret et utile : un module qui prépare HashiCorp Vault pour une application. On le construit étape par étape, on l'appelle, puis on le réutilise pour une deuxième application. La vidéo d'abord, le détail du code ensuite.
Vault en deux phrases
Vault est un coffre-fort pour vos secrets : mots de passe de bases de données, clés d'API, certificats. Une application s'y authentifie, reçoit un token, et une policy décide précisément ce qu'elle a le droit de lire. Plus de mots de passe éparpillés dans des fichiers .env sur chaque serveur.
Un module, c'est une fonction
Un module Terraform, c'est simplement un dossier de fichiers .tf qu'on peut appeler depuis un autre projet. La meilleure analogie reste celle de la fonction :
- des variables en entrée (les paramètres) ;
- des ressources créées à l'intérieur (le corps de la fonction) ;
- des outputs en sortie (la valeur de retour).
Notre objectif : un module qui, pour une application donnée, crée un point de montage pour ses secrets, y range ces secrets, et crée une policy qui donne le droit de les lire. Et rien de plus.
Étape 1 : la structure
modules/vault-app/
├── main.tf # les ressources
├── variables.tf # les entrées
├── outputs.tf # les sorties
├── versions.tf # les providers requis
└── README.md # comment l'utiliser
Le README n'est pas optionnel. Un module sans documentation, c'est un module que personne n'ose utiliser, y compris vous-même dans six mois.
Étape 2 : versions.tf
terraform {
required_version = ">= 1.6"
required_providers {
vault = {
source = "hashicorp/vault"
version = "~> 4.0"
}
}
}
Le module déclare de quoi il a besoin, et fixe une plage de versions. Une mise à jour majeure du provider ne viendra pas le casser par surprise.
Étape 3 : variables.tf
variable "mount_path" {
type = string
description = "Chemin du point de montage KV (ex: app-shop)"
validation {
condition = can(regex("^[a-z0-9-]+$", var.mount_path))
error_message = "Le chemin ne doit contenir que des minuscules, chiffres et tirets."
}
}
variable "policy_name" {
type = string
description = "Nom de la policy de lecture créée pour l'application"
}
variable "secrets" {
type = map(map(string))
description = "Secrets à créer : { nom = { clé = valeur } }"
sensitive = true
}
Trois points qui font la différence entre un module bricolé et un module fiable :
- chaque variable a un type et une description ;
sensitive = trueempêche les secrets de s'afficher dans la sortie deplanetapply;- le bloc
validationrefuse une valeur incorrecte dès leplan, avec un message clair, plutôt qu'une erreur obscure renvoyée par l'API de Vault.
Étape 4 : main.tf, en trois ressources
Le point de montage
resource "vault_mount" "kv" {
path = var.mount_path
type = "kv"
options = { version = "2" }
description = "Secrets de ${var.mount_path}"
}
On active le moteur de secrets KV version 2, qui garde un historique des versions de chaque secret (pratique pour revenir en arrière après une erreur de saisie).
Les secrets, avec for_each
resource "vault_kv_secret_v2" "this" {
for_each = nonsensitive(toset(keys(var.secrets)))
mount = vault_mount.kv.path
name = each.key
data_json = jsonencode(var.secrets[each.key])
}
Un seul bloc, répété pour chaque secret de la liste : un pour la base de données, un pour le service de paiement, autant que nécessaire. Le nonsensitive() sur les clés est nécessaire parce que Terraform refuse d'utiliser une valeur sensible comme identifiant de ressource ; les valeurs, elles, restent protégées.
La policy, en moindre privilège
resource "vault_policy" "read" {
name = var.policy_name
policy = <<-EOT
path "${vault_mount.kv.path}/data/*" {
capabilities = ["read"]
}
EOT
}
Un seul droit : la lecture. Et seulement sur ce point de montage. L'application ne peut ni écrire, ni lister les autres applications, ni rien supprimer. C'est le principe du moindre privilège, et c'est tout l'intérêt de générer la policy dans le module : personne ne pourra « oublier » de la restreindre.
Notez le /data/ dans le chemin : avec KV v2, les données sont exposées sous <montage>/data/<secret>. C'est un piège classique quand on vient de KV v1.
Étape 5 : outputs.tf
output "mount_path" {
value = vault_mount.kv.path
description = "Chemin du point de montage créé"
}
output "policy_name" {
value = vault_policy.read.name
description = "Policy à attacher au rôle d'authentification de l'application"
}
Le reste du projet peut ensuite brancher ces valeurs ailleurs, par exemple sur un rôle AppRole ou Kubernetes qui donnera son token à l'application.
Étape 6 : appeler le module
# projet principal : main.tf
provider "vault" {
address = "https://vault.monsite.fr"
# le token vient de la variable d'environnement VAULT_TOKEN
}
module "vault_shop" {
source = "./modules/vault-app"
mount_path = "app-shop"
policy_name = "shop-read"
secrets = var.shop_secrets
}
Le token Vault n'apparaît jamais dans le code : il passe par la variable d'environnement VAULT_TOKEN. Et les secrets eux-mêmes arrivent via une variable (fichier .tfvars non commité, variable TF_VAR_shop_secrets, ou variable masquée dans la CI).
Remarquez aussi que le bloc provider est dans le projet principal, pas dans le module. J'y reviens dans les bonnes pratiques.
Étape 7 : init, plan, apply
export VAULT_TOKEN=... # ou via votre méthode d'authentification
terraform init
terraform plan
# Plan: 4 to add, 0 to change, 0 to destroy.
terraform apply
Avec deux secrets, le plan annonce quatre ressources : le point de montage, les deux secrets, la policy. Les valeurs des secrets apparaissent comme (sensitive value).
Étape 8 : la vraie force du module
Une deuxième application arrive, la facturation. Pas besoin de réécrire quoi que ce soit :
module "vault_billing" {
source = "./modules/vault-app"
mount_path = "app-billing"
policy_name = "billing-read"
secrets = var.billing_secrets
}
Deux applications, zéro copier-coller. Et si demain vous décidez d'ajouter une policy d'écriture pour un outil d'administration, vous le faites une fois dans le module, et toutes les applications en profitent.
Étape 9 : versionner et partager
Tant que le module vit dans le même dépôt, un chemin local suffit. Dès qu'il est partagé entre plusieurs projets, on le sort dans son propre dépôt Git, et on pose des tags de version :
module "vault_shop" {
source = "git::https://gitlab.com/infra/terraform-vault-app.git?ref=v1.2.0"
# ...
}
Chaque projet choisit la version qu'il utilise et migre à son rythme. À l'échelle d'une entreprise, on publie le module dans un registre privé (GitLab en propose un, tout comme Terraform Enterprise), avec documentation et contraintes de version (version = "~> 1.2").
Les bonnes pratiques d'un bon module
- Une seule responsabilité : ce module prépare Vault pour une application. Il ne crée pas aussi le serveur, la base de données et le DNS.
- Pas de bloc
providerà l'intérieur : c'est l'appelant qui configure le provider. Un module qui embarque son provider ne peut pas être utilisé avecfor_eachni pointé vers un autre Vault. - Des variables documentées et validées.
- Attention au state : les secrets créés par Terraform y sont enregistrés en clair. Le backend doit être chiffré, et son accès très restreint. Pour les secrets les plus sensibles, une alternative consiste à ne créer avec Terraform que la structure (montages, policies) et à laisser les valeurs être saisies directement dans Vault.
À retenir
Un module, c'est des variables en entrée, des ressources, et des outputs en sortie. On fixe les versions, on valide les entrées, on ne met aucun secret dans le code, et on le réutilise partout.
Si les bases de Terraform ne sont pas encore claires, commencez par mon article « Terraform expliqué de A à Z ». Et pour aller plus loin, l'article sur Terraform Enterprise montre comment publier ce module dans un registre privé et l'exécuter en équipe.