Modules Terraform : structure et appels
À la fin de ce tutoriel, vous saurez pourquoi modulariser un projet Terraform, poser une structure
modules/xxx/(main.tf,variables.tf,outputs.tf,versions.tf), appeler un module avecmodule "name" { source = … }, câbler inputs / outputs, distinguer root module et child module, puis valider un lab léger (module S3 ou tags wrapper) en Regionca-central-1avec destroy systématique — et éviter les erreurs classiques (cheminsource, outputs manquants).Niveau : Débutant → Intermédiaire · Temps estimé : 40–50 min · Versions testées : Terraform ≥ 1.9, AWS provider ~> 5.0 · Dernière vérification : 2026-09-10
Slug proposé :
terraform-modules-bases· Série : Terraform · Remplace / fusionne : #1699 partie 1 (Modules — le #1699 long sera coupé en bases + registry)
Prérequis
- Avoir suivi Dynamic blocks (
terraform-dynamic-blocks) : nested blocks,for_each, lab SG - Avoir suivi Variables (
terraform-variables) : types, validation, tfvars - Avoir suivi Outputs (
terraform-outputs) :value,sensitive, re-export - Terraform ≥ 1.9, AWS CLI v2, profil de lab (
aws sts get-caller-identityOK)
Coût estimé : quasi 0 € (bucket S3 lab + tags ; Free Tier / faible coût). Destroy obligatoire. Aucun secret dans le HCL / tfvars.
Auth sans secrets. Utilisez
AWS_PROFILE/ SSO. Jamais d’access_key/secret_keydans le provider. Pour ce lab :s3:CreateBucket,PutBucket,DeleteBucketsur le compte de lab suffisent.
Ce que nous allons construire
Lab modules Terraform bases (ca-central-1)
├── versions.tf / providers.tf → TF ≥ 1.9, aws ~> 5.0, region ca-central-1
├── variables.tf / locals.tf → project, environment, tags communs
├── modules/s3_lab/ → child : main, variables, outputs, versions
├── main.tf → module "lab_bucket" { source = "./modules/s3_lab" }
├── outputs.tf → re-export bucket_id / bucket_arn
└── init → plan → apply → destroy
(Alt : « Modules Terraform : structure dossier modules/xxx et appels source locaux — lab S3 ca-central-1 ».)
Étape 1 — Pourquoi modulariser
Sans modules, un monolithe main.tf de plusieurs centaines de lignes mélange VPC, S3, IAM et tags. Dupliquer le même pattern (bucket + versioning + block public access) dans trois environnements crée de la dette : un correctif sécurité doit être recopié N fois.
Les terraform modules encapsulent une intention réutilisable :
| Bénéfice | Concret |
|---|---|
| Réutilisation | Un enfant modules/s3_lab appelé depuis root (dev/staging) |
| Frontière claire | Inputs typés + outputs documentés = contrat d’API |
| Lisibilité root | Le root orchestre ; le child implémente |
| Testabilité | Vous validez un module isolé avant de l’intégrer |
| Évolution | Registry (tuto suivant) pour partager hors du repo |
Règle P2 : modularisez dès qu’un pattern se répète ou qu’une équipe veut une interface stable — pas avant un premier apply plat qui marche.
Étape 2 — Structure dossier modules/xxx/
Convention de dépôt recommandée (enfant local) :
.
├── main.tf # root : appels module "…"
├── variables.tf # inputs du root
├── outputs.tf # re-export éventuel
├── versions.tf # required_version + providers root
├── providers.tf
└── modules/
└── s3_lab/ # child module
├── main.tf # resources du module
├── variables.tf # inputs du module
├── outputs.tf # outputs du module
└── versions.tf # contraintes version (optionnel mais recommandé)
Rôles des fichiers child :
| Fichier | Rôle |
|---|---|
main.tf |
Resources / data / locals internes |
variables.tf |
Inputs (variable "…") — seule porte d’entrée |
outputs.tf |
Valeurs exposées au caller (output "…") |
versions.tf |
required_version / required_providers du child |
Le child ne déclare pas de provider "aws" en dur (sauf multi-provider avancé) : il hérite du root. Pas de secrets, ni backend, dans le child.
Étape 3 — Appel module "name" { source = … }
Au root, un bloc module instancie le child :
module "lab_bucket" {
source = "./modules/s3_lab"
bucket_prefix = var.bucket_prefix
environment = var.environment
common_tags = local.common_tags
}
Points clés :
| Élément | Rôle |
|---|---|
"lab_bucket" |
Label local (adresse state : module.lab_bucket.…) |
source |
Chemin relatif, registry, git — ici chemin local |
| Arguments nommés | Doivent correspondre aux variable du child |
| Adresse | Resources internes : module.lab_bucket.aws_s3_bucket.this |
Après modification de source ou du module, relancez terraform init. Le chemin ./modules/s3_lab est relatif au root du projet.
Étape 4 — Inputs / outputs du module
Inputs = variables du child. Déclarez types, descriptions et validations ; passez les valeurs depuis le root.
# modules/s3_lab/variables.tf
variable "bucket_prefix" {
type = string
description = "Préfixe du nom de bucket (suffixe aléatoire ajouté)."
}
variable "environment" {
type = string
description = "Environnement logique (lab, dev…)."
validation {
condition = contains(["lab", "dev", "staging", "prod"], var.environment)
error_message = "environment doit être lab, dev, staging ou prod."
}
}
variable "common_tags" {
type = map(string)
description = "Tags fusionnés sur le bucket."
default = {}
}
Outputs = face publique. Sans output, le root ne peut pas lire un attribut du child via module.lab_bucket.… (sauf en plongeant dans l’adresse resource — à éviter).
# modules/s3_lab/outputs.tf
output "bucket_id" {
description = "Nom/ID du bucket créé."
value = aws_s3_bucket.this.id
}
output "bucket_arn" {
description = "ARN du bucket."
value = aws_s3_bucket.this.arn
}
Au root, re-exportez si CI / humains ont besoin de la valeur :
# root outputs.tf
output "lab_bucket_id" {
description = "ID du bucket de lab (re-export module)."
value = module.lab_bucket.bucket_id
}
output "lab_bucket_arn" {
description = "ARN du bucket de lab (re-export module)."
value = module.lab_bucket.bucket_arn
}
Rappel : sensitive = true masque la CLI — pas le state (tuto Outputs).
Étape 5 — Root vs child module
| Concept | Définition | Fichiers typiques |
|---|---|---|
| Root module | Répertoire où vous lancez terraform init/plan/apply |
main.tf, providers.tf, appels module |
| Child module | Répertoire référencé par source |
modules/xxx/* |
| Composition | Root orchestre N children ; children peuvent appeler d’autres children | Évitez les graphes trop profonds (> 2–3 niveaux) |
Le state appartient au root : les resources du child y apparaissent sous module.NOM.TYPE.LABEL. Un terraform state list montre clairement la hiérarchie.
Anti-patterns : provider/clés dans le child ; lire module.x.aws_s3_bucket.y.id sans output ; modulariser un fichier de 40 lignes « pour le plaisir ».
Étape 6 — Lab ca-central-1 (module S3 + destroy)
Objectif : child modules/s3_lab qui crée un bucket privé, appelé depuis le root en Region ca-central-1, puis destroy.
# versions.tf (root)
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# providers.tf (root)
provider "aws" {
region = "ca-central-1"
# Auth : AWS_PROFILE / SSO — jamais de clés ici
}
# variables.tf (root)
variable "bucket_prefix" {
type = string
description = "Préfixe unique (initiales + date)."
}
variable "environment" {
type = string
default = "lab"
}
# locals.tf (root)
locals {
common_tags = {
ManagedBy = "terraform"
Lab = "modules-bases"
Env = var.environment
}
}
# modules/s3_lab/versions.tf
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
}
}
# modules/s3_lab/main.tf
resource "aws_s3_bucket" "this" {
bucket_prefix = "${var.bucket_prefix}-"
tags = merge(var.common_tags, {
Name = "${var.bucket_prefix}-${var.environment}"
})
}
resource "aws_s3_bucket_public_access_block" "this" {
bucket = aws_s3_bucket.this.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# modules/s3_lab/variables.tf + outputs.tf → voir étape 4
# main.tf (root)
module "lab_bucket" {
source = "./modules/s3_lab"
bucket_prefix = var.bucket_prefix
environment = var.environment
common_tags = local.common_tags
}
Commandes :
mkdir -p ~/terraform-modules-bases-lab/modules/s3_lab
cd ~/terraform-modules-bases-lab
# collez les fichiers root + modules/s3_lab/{main,variables,outputs,versions}.tf
export AWS_PROFILE=lab
export AWS_REGION=ca-central-1
terraform init
terraform fmt -recursive
terraform validate
terraform plan -var="bucket_prefix=votreprenom-20260910"
terraform apply -auto-approve -var="bucket_prefix=votreprenom-20260910"
terraform output
# Vérification rapide (optionnel)
aws s3api get-bucket-location
--bucket "$(terraform output -raw lab_bucket_id)"
--region ca-central-1
terraform destroy -auto-approve -var="bucket_prefix=votreprenom-20260910"
Observez le plan : resources module.lab_bucket.*. Un tag dans common_tags → update in-place. Variante sans cloud : child « tags wrapper » qui n’expose qu’un output "merged" — utile pour inputs/outputs seuls.
Étape 7 — Checklist
- Pattern répété ou contrat d’équipe → module ; sinon restez plat.
- Child =
modules/xxx/avecmain/variables/outputs/versions. - Appel root :
module "name" { source = "./modules/xxx" … }. - Inputs typés + validations ; outputs pour tout ce que le root doit lire.
- Root orchestre, child implémente ; pas de provider/clés dans le child.
- Après changement de module :
terraform initpuis plan. - Lab
ca-central-1+ destroy systématique ; aucun secret dans le HCL.
Nettoyage
cd ~/terraform-modules-bases-lab
terraform destroy -auto-approve -var="bucket_prefix=votreprenom-20260910"
rm -rf ~/terraform-modules-bases-lab
Confirmez l’absence du bucket en CLI (ca-central-1). Si destroy échoue (objets restants), videz puis relancez.
Erreurs fréquentes
| Symptôme | Cause | Correction |
|---|---|---|
Module not installed / source introuvable |
Mauvais chemin relatif source |
Vérifier ./modules/s3_lab depuis le root ; terraform init |
Unsupported argument sur module |
Input passé sans variable correspondante dans le child |
Ajouter la variable ou retirer l’argument |
This object does not have an attribute named "…" |
Output manquant côté child | Déclarer output puis module.x.nom_output |
| Plan vide / module ignoré | Fichiers hors du dossier source |
Placer main.tf dans modules/xxx/ |
| Provider manquant dans le child | required_providers trop strict / version mismatch |
Aligner versions.tf child et root (~> 5.0) |
| State orphelin après rename | module "a" → "b" sans move |
terraform state mv ou destroy/recreate |
Quiz (3 questions)
1. Que contient typiquement modules/s3_lab/ ?
- A. Uniquement un
backend "s3" - B.
main.tf,variables.tf,outputs.tf(+versions.tfrecommandé) - C. Obligatoirement un
provider "aws"avec access keys
2. Comment le root lit-il l’ID du bucket créé dans le child ?
- A. Via un output du module :
module.lab_bucket.bucket_id - B. Uniquement via la console AWS
- C. En lisant
.terraform/modulesà la main
3. Quelle est la différence root vs child ?
- A. Aucune : synonymes HashiCorp
- B. Le root est le dossier d’
apply; le child est appelé viasource - C. Le child possède toujours le state distant
Réponses : 1‑B · 2‑A · 3‑B
Pourquoi / quand créer un module local
Dès qu’un même pattern se répète (bucket sécurisé, SG minimal, paire VPC légère) ou qu’une équipe veut un contrat d’inputs/outputs, un module local dans modules/xxx clarifie le root. N’en créez pas pour une seule resource jetable : la surcharge de fichiers doit payer en clarté.
Anatomie mentale root vs child
- Root : dossier où vous lancez
init/plan/apply; orchestre ; détient souvent le backend et les providers. - Child : appelé via
module "name" { source = "./modules/…" }; exposevariable/output; implémente lesresource. - Le state du root contient les adresses préfixées
module.NAME.TYPE.NAME.
Pièges modules (bases)
sourcerelatif incorrect (../de trop) → module non installé.- Passer un argument absent des
variabledu child →Unsupported argument. - Oublier un
outputpuis essayermodule.x.iddepuis le root. - Déclarer un
provideravec clés dans le child : anti-pattern ; héritez des providers du root (auth profil, Regionca-central-1). - Renommer
module "a"en"b"sansmoved/state mv→ recreate. - Publier trop tôt sur le Registry : maîtrisez d’abord le module local (ce tuto), TF ≥ 1.9.
Quand factoriser vs rester plat
| Signal | Action |
|---|---|
| Copier-coller 3+ fois le même trio S3 | Module s3_lab |
| Une resource unique de démo | Rester plat |
| Contrat d’équipe (tags, encryption) | Module + validations d’inputs |
| Variantes très divergentes | Modules séparés plutôt qu’un mega-module opaque |
Bonnes pratiques d’inputs/outputs
- Chaque input :
type+description(+validationpour préfixes). - Outputs : uniquement ce que le root doit composer (id, arn, name).
- Versions :
required_providersaligné~> 5.0dans child et root. - Après clone/changement de module :
terraform initpuis plan avant apply. - Destroy du lab module = destroy des resources préfixées
module.*.
FAQ modules bases
Un module doit-il contenir un backend ? Non : le backend appartient au root (ou à la stratège remote).
Puis-je nested des modules ? Oui, avec modération ; la profondeur tue la lisibilité des plans.
Comment tester sans AWS ? Variante « tags wrapper » évoquée dans le lab : outputs purs ; sinon plan suffit souvent.
for_each sur un module ? Oui en Terraform moderne — combinez avec le tuto Boucles une fois les inputs stables.
Secrets ? Jamais dans les defaults du module versionné ; injectez via variables sensibles non commitées / store externe.
Prochaine étape ? Modules registry : sources versionnées, contraintes, consommation d’un module publié.
Pourquoi versions.tf dans le child ? Documenter la contrainte provider minimale évite les surprises quand le root dérive.
Pour aller plus loin
- Doc : Modules, structure, appel
- Sur ce site : Dynamic blocks, Variables, Outputs ; suite Modules registry
Maillage série Terraform (P2)
| ← Précédent | Dynamic blocks (terraform-dynamic-blocks) |
| → Suivant | Modules registry (terraform-modules-registry) |
| Aussi | Outputs (terraform-outputs) · Variables (terraform-variables) |
| Carte | Overview · Install · Workflow · Blocks · Providers · Variables · Locals · State · Modules · Workspaces · Labs AWS |
Cas réel — un module « fourre-tout » impossible à versionner
Un dossier modules/app qui crée VPC + RDS + IAM + alarmes ne se réutilise pas : chaque appelant veut un sous-ensemble. Découpez (réseau / data / iam). Versionnez par tag Git ou Registry. Un module sans outputs utiles force les appelants à plonger dans le state enfant.
Piège : passer 40 variables « au cas où ». Préférez des objets typés et des defaults honnêtes. Les breaking changes (rename d’output, ForceNew caché) se voient dans le plan de l’appelant — testez le module avec un wrapper de lab en ca-central-1 avant de le publier.
À retenir : un bon module a une phrase de responsabilité. S’il vous en faut trois, ce sont trois modules.
Retour parcours Terraform — hub de la série et leçons sœurs.



