DevOps Elastic Hayway
Document

SUBSCRIBE TO GET FULL ACCESS TO THE E-BOOKS FOR FREE 🎁SUBSCRIBE NOW

Professional Dropdown with Icon

SUBSCRIBE NOW TO GET FREE ACCESS TO EBOOKS

TerraformLesson 23 / 2412 min readUpdated September 13, 2026

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 avec module "name" { source = … }, câbler inputs / outputs, distinguer root module et child module, puis valider un lab léger (module S3 ou tags wrapper) en Region ca-central-1 avec destroy systématique — et éviter les erreurs classiques (chemin source, 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-identity OK)

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_key dans le provider. Pour ce lab : s3:CreateBucket, PutBucket, DeleteBucket sur 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

  1. Pattern répété ou contrat d’équipe → module ; sinon restez plat.
  2. Child = modules/xxx/ avec main / variables / outputs / versions.
  3. Appel root : module "name" { source = "./modules/xxx" … }.
  4. Inputs typés + validations ; outputs pour tout ce que le root doit lire.
  5. Root orchestre, child implémente ; pas de provider/clés dans le child.
  6. Après changement de module : terraform init puis plan.
  7. 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.tf recommandé)
  • 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é via source
  • 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/…" } ; expose variable / output ; implémente les resource.
  • Le state du root contient les adresses préfixées module.NAME.TYPE.NAME.

Pièges modules (bases)

  • source relatif incorrect (../ de trop) → module non installé.
  • Passer un argument absent des variable du child → Unsupported argument.
  • Oublier un output puis essayer module.x.id depuis le root.
  • Déclarer un provider avec clés dans le child : anti-pattern ; héritez des providers du root (auth profil, Region ca-central-1).
  • Renommer module "a" en "b" sans moved / 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 (+ validation pour préfixes).
  • Outputs : uniquement ce que le root doit composer (id, arn, name).
  • Versions : required_providers aligné ~> 5.0 dans child et root.
  • Après clone/changement de module : terraform init puis 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.

Share your love

Leave a Reply

Your email address will not be published. Required fields are marked *