À mesure que vos projets Terraform grandissent, vos fichiers de configuration deviennent de plus en plus complexes et difficiles à maintenir. Vous vous retrouvez à copier-coller des blocs de code entre projets, à gérer des centaines de lignes dans un seul fichier, et chaque modification devient risquée. Les modules Terraform sont la solution à ce problème. Ils vous permettent de structurer, organiser et surtout réutiliser votre Infrastructure as Code de manière élégante et professionnelle.
Dans cet article, nous allons explorer en profondeur les modules Terraform : leur fonctionnement, leur structure, les bonnes pratiques pour les créer, et comment les utiliser efficacement dans vos projets. Nous construirons ensemble un module réseau réutilisable complet que vous pourrez adapter à vos besoins.

Qu'est-ce qu'un Module Terraform ?
Un module Terraform est tout simplement un ensemble de fichiers de configuration Terraform regroupés dans un répertoire. En réalité, vous utilisez déjà des modules sans le savoir : chaque répertoire contenant des fichiers .tf est un module. Le répertoire racine de votre projet est ce qu'on appelle le module root (module racine).
Un module encapsule un ensemble de ressources qui fonctionnent ensemble pour remplir une fonction précise. Par exemple, un module réseau pourrait créer un VPC, des sous-réseaux, des tables de routage et des passerelles — tout ce qui constitue l'infrastructure réseau d'un projet.
Analogie : Si Terraform est un langage de programmation, les modules sont l'équivalent des fonctions. Ils prennent des paramètres en entrée (variables), exécutent une logique (création de ressources) et retournent des résultats (outputs).
Pourquoi Modulariser votre Infrastructure ?
La modularisation de votre code Terraform apporte de nombreux avantages concrets :
- Réutilisabilité : Un module bien conçu peut être utilisé dans plusieurs projets sans modification. Votre module réseau peut servir pour le projet A, B et C.
- Maintenabilité : Au lieu de chercher dans des milliers de lignes, chaque composant est isolé dans son propre module avec une responsabilité claire.
- Cohérence : En utilisant les mêmes modules partout, vous garantissez que vos environnements suivent les mêmes standards et bonnes pratiques.
- Collaboration : Les équipes peuvent travailler sur différents modules indépendamment, réduisant les conflits et accélérant le développement.
- Testabilité : Un module isolé est plus facile à tester qu'une configuration monolithique.
- Abstraction : Les utilisateurs du module n'ont pas besoin de connaître les détails d'implémentation. Ils fournissent des variables et récupèrent des outputs.
Structure d'un Module Terraform
Un module Terraform suit une convention de nommage de fichiers bien établie. Voici la structure standard recommandée par HashiCorp :
mon-module/
├── main.tf # Ressources principales du module
├── variables.tf # Variables d'entrée du module
├── outputs.tf # Valeurs de sortie du module
├── versions.tf # Contraintes de version Terraform et providers
├── README.md # Documentation du module
├── examples/ # Exemples d'utilisation
│ └── simple/
│ └── main.tf
└── tests/ # Tests du module (optionnel)
main.tf — Le cœur du module
Ce fichier contient les ressources principales que le module va créer. C'est ici que réside la logique d'infrastructure :
# main.tf
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = var.enable_dns_hostnames
enable_dns_support = true
tags = merge(var.common_tags, {
Name = "${var.project_name}-vpc"
})
}
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(var.common_tags, {
Name = "${var.project_name}-public-subnet-${count.index + 1}"
Type = "Public"
})
}
variables.tf — Les paramètres d'entrée
Ce fichier définit toutes les variables que le module accepte. Chaque variable doit avoir une description claire et, idéalement, une valeur par défaut lorsque cela a du sens :
# variables.tf
variable "project_name" {
description = "Nom du projet, utilisé pour le nommage des ressources"
type = string
}
variable "vpc_cidr" {
description = "Bloc CIDR pour le VPC"
type = string
default = "10.0.0.0/16"
validation {
condition = can(cidrhost(var.vpc_cidr, 0))
error_message = "La valeur doit être un bloc CIDR valide."
}
}
variable "public_subnet_cidrs" {
description = "Liste des blocs CIDR pour les sous-réseaux publics"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24"]
}
variable "availability_zones" {
description = "Liste des zones de disponibilité à utiliser"
type = list(string)
}
variable "enable_dns_hostnames" {
description = "Activer les noms DNS dans le VPC"
type = bool
default = true
}
variable "common_tags" {
description = "Tags communs à appliquer à toutes les ressources"
type = map(string)
default = {}
}
outputs.tf — Les valeurs de sortie
Ce fichier expose les valeurs que le module retourne à celui qui l'appelle. Les outputs permettent de chaîner les modules entre eux :
# outputs.tf
output "vpc_id" {
description = "L'ID du VPC créé"
value = aws_vpc.main.id
}
output "vpc_cidr" {
description = "Le bloc CIDR du VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "Liste des IDs des sous-réseaux publics"
value = aws_subnet.public[*].id
}
output "public_subnet_cidrs" {
description = "Liste des blocs CIDR des sous-réseaux publics"
value = aws_subnet.public[*].cidr_block
}
versions.tf — Les contraintes de version
Ce fichier spécifie les versions minimales de Terraform et des providers requis par le module :
# versions.tf
terraform {
required_version = ">= 1.0.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 4.0.0"
}
}
}
Modules Locaux vs Modules du Registry
Terraform permet d'utiliser des modules provenant de différentes sources. Les deux principales sont les modules locaux et les modules du Terraform Registry.
Modules locaux
Les modules locaux sont stockés directement dans votre projet, généralement dans un répertoire modules/. Ils sont référencés par leur chemin relatif :
module "network" {
source = "./modules/network"
project_name = "mon-projet"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["eu-west-3a", "eu-west-3b"]
}
Les modules locaux sont parfaits pour du code spécifique à votre organisation, en cours de développement, ou qui n'a pas vocation à être partagé publiquement.
Modules du Terraform Registry
Le Terraform Registry (registry.terraform.io) est un dépôt public de modules créés par la communauté et par HashiCorp. On y trouve des modules pour la plupart des cas d'usage courants :
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0"
name = "mon-vpc"
cidr = "10.0.0.0/16"
azs = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
}
D'autres sources sont également supportées :
- GitHub :
source = "github.com/organisation/terraform-aws-module" - Bitbucket :
source = "bitbucket.org/organisation/terraform-aws-module" - S3 :
source = "s3::https://mon-bucket.s3.amazonaws.com/module.zip" - GCS :
source = "gcs::https://www.googleapis.com/storage/v1/modules/module.zip" - Git générique :
source = "git::https://example.com/module.git?ref=v1.0.0"
Appeler un Module et Passer des Variables
L'appel d'un module se fait avec le bloc module. Vous spécifiez la source et passez les valeurs des variables définies dans le module :
# main.tf (module racine)
provider "aws" {
region = "eu-west-3"
}
module "network" {
source = "./modules/network"
project_name = var.project_name
vpc_cidr = "10.0.0.0/16"
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
availability_zones = ["eu-west-3a", "eu-west-3b"]
enable_dns_hostnames = true
common_tags = {
Environment = "production"
ManagedBy = "terraform"
}
}
module "security" {
source = "./modules/security"
vpc_id = module.network.vpc_id
project_name = var.project_name
allowed_ssh_cidrs = ["203.0.113.0/24"]
}
Remarquez comment le module security utilise module.network.vpc_id pour récupérer l'output du module network. C'est ainsi que les modules communiquent entre eux.
Récupérer les Outputs d'un Module
Les outputs d'un module sont accessibles via la syntaxe module.<NOM_MODULE>.<NOM_OUTPUT>. Vous pouvez les utiliser dans d'autres modules, dans des ressources, ou les exposer comme outputs de votre module racine :
# Utilisation dans une ressource
resource "aws_instance" "web" {
ami = "ami-0123456789abcdef0"
instance_type = "t3.micro"
subnet_id = module.network.public_subnet_ids[0]
vpc_security_group_ids = [module.security.web_sg_id]
tags = {
Name = "web-server"
}
}
# Exposition comme output du module racine
output "vpc_id" {
description = "L'ID du VPC"
value = module.network.vpc_id
}
output "public_subnets" {
description = "Les sous-réseaux publics"
value = module.network.public_subnet_ids
}
Modules Imbriqués : Composer des Modules
Les modules peuvent appeler d'autres modules, créant ainsi une hiérarchie de modules. C'est une technique puissante pour créer des abstractions de haut niveau :
infrastructure/
├── main.tf
└── modules/
├── application/ # Module de haut niveau
│ ├── main.tf # Appelle network et compute
│ ├── variables.tf
│ └── outputs.tf
├── network/ # Module réseau
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── compute/ # Module calcul
├── main.tf
├── variables.tf
└── outputs.tf
# modules/application/main.tf
module "network" {
source = "../network"
project_name = var.project_name
vpc_cidr = var.vpc_cidr
availability_zones = var.availability_zones
}
module "compute" {
source = "../compute"
project_name = var.project_name
vpc_id = module.network.vpc_id
subnet_ids = module.network.public_subnet_ids
instance_type = var.instance_type
instance_count = var.instance_count
}
Attention : Évitez d'imbriquer les modules sur trop de niveaux (maximum 3-4 niveaux). Une imbrication excessive rend le code difficile à comprendre et à déboguer. HashiCorp recommande de privilégier une structure plate lorsque possible.
Versioning des Modules
Le versioning est essentiel pour la stabilité de vos déploiements. Il permet de contrôler exactement quelle version d'un module est utilisée et d'éviter les surprises lors des mises à jour.
Pour les modules du Registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "5.1.0" # Version exacte
# ou avec des contraintes
# version = "~> 5.0" # >= 5.0.0 et < 6.0.0
# version = ">= 5.0, < 5.5" # Entre 5.0.0 et 5.4.x
}
Pour les modules Git
module "network" {
source = "git::https://github.com/mon-org/terraform-modules.git//network?ref=v1.2.0"
}
Le paramètre ref peut être un tag, une branche ou un commit SHA. Les tags sont recommandés pour la production :
ref=v1.2.0— Un tag spécifique (recommandé)ref=main— Une branche (risqué en production)ref=abc1234— Un commit SHA (très précis mais peu lisible)
Stratégie de versioning sémantique
Adoptez le versioning sémantique (SemVer) pour vos modules : MAJOR.MINOR.PATCH.
- MAJOR : Changements incompatibles (suppression de variables, renommage de ressources)
- MINOR : Nouvelles fonctionnalités rétrocompatibles (ajout de variables optionnelles)
- PATCH : Corrections de bugs sans changement d'interface
Le Terraform Registry en Détail
Le Terraform Registry est la plateforme officielle de partage de modules. On y trouve deux types de modules :
- Modules vérifiés (Verified) : Maintenus par HashiCorp ou des partenaires, avec un badge de vérification. Exemples : terraform-aws-modules/vpc/aws, terraform-google-modules/network/google.
- Modules communautaires : Créés et maintenus par la communauté. La qualité varie, vérifiez toujours les étoiles GitHub, la fréquence des mises à jour et les issues ouvertes.
Pour utiliser un module du Registry, la syntaxe est <NAMESPACE>/<NAME>/<PROVIDER> :
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "3.14.0"
bucket = "mon-bucket-unique"
acl = "private"
versioning = {
enabled = true
}
server_side_encryption_configuration = {
rule = {
apply_server_side_encryption_by_default = {
sse_algorithm = "AES256"
}
}
}
}
Créer et Publier un Module sur le Registry
Pour publier un module sur le Terraform Registry, vous devez respecter certaines conventions :
Convention de nommage du dépôt
Le dépôt GitHub doit suivre le format : terraform-<PROVIDER>-<NAME>. Par exemple : terraform-aws-network, terraform-azure-webapp, terraform-google-gke.
Structure requise
terraform-aws-network/
├── main.tf # Ressources principales (obligatoire)
├── variables.tf # Variables d'entrée (obligatoire)
├── outputs.tf # Valeurs de sortie (obligatoire)
├── versions.tf # Contraintes de version
├── README.md # Documentation (obligatoire)
├── LICENSE # Licence open-source
├── examples/ # Exemples d'utilisation (recommandé)
│ ├── simple/
│ │ ├── main.tf
│ │ └── outputs.tf
│ └── complete/
│ ├── main.tf
│ └── outputs.tf
└── modules/ # Sous-modules (optionnel)
├── subnet/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
└── security-group/
├── main.tf
├── variables.tf
└── outputs.tf
Publication étape par étape
- Créez votre dépôt GitHub avec la convention de nommage
- Structurez votre code selon les conventions ci-dessus
- Ajoutez des tags de version (
git tag v1.0.0 && git push --tags) - Connectez-vous au Terraform Registry avec votre compte GitHub
- Cliquez sur "Publish" et sélectionnez votre dépôt
- Le Registry détectera automatiquement les tags et publiera les versions
Exemple Complet : Module Réseau AWS Réutilisable
Construisons ensemble un module réseau complet et production-ready. Ce module crée un VPC avec des sous-réseaux publics et privés, une passerelle Internet, des passerelles NAT et les tables de routage associées.
Structure du module
modules/network/
├── main.tf
├── variables.tf
├── outputs.tf
└── versions.tf
variables.tf
# modules/network/variables.tf
variable "project_name" {
description = "Nom du projet pour le tagging des ressources"
type = string
}
variable "environment" {
description = "Environnement (dev, staging, prod)"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "L'environnement doit être dev, staging ou prod."
}
}
variable "vpc_cidr" {
description = "Bloc CIDR du VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidrs" {
description = "Liste des CIDR pour les sous-réseaux publics"
type = list(string)
default = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
}
variable "private_subnet_cidrs" {
description = "Liste des CIDR pour les sous-réseaux privés"
type = list(string)
default = ["10.0.10.0/24", "10.0.20.0/24", "10.0.30.0/24"]
}
variable "availability_zones" {
description = "Liste des zones de disponibilité"
type = list(string)
}
variable "enable_nat_gateway" {
description = "Activer la passerelle NAT pour les sous-réseaux privés"
type = bool
default = true
}
variable "single_nat_gateway" {
description = "Utiliser une seule NAT Gateway (économie de coûts)"
type = bool
default = false
}
variable "common_tags" {
description = "Tags communs à appliquer à toutes les ressources"
type = map(string)
default = {}
}
main.tf
# modules/network/main.tf
locals {
nat_gateway_count = var.enable_nat_gateway ? (var.single_nat_gateway ? 1 : length(var.public_subnet_cidrs)) : 0
default_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
}
tags = merge(local.default_tags, var.common_tags)
}
# ========================================
# VPC
# ========================================
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-vpc"
})
}
# ========================================
# Passerelle Internet
# ========================================
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-igw"
})
}
# ========================================
# Sous-réseaux publics
# ========================================
resource "aws_subnet" "public" {
count = length(var.public_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-public-${count.index + 1}"
Type = "Public"
})
}
# ========================================
# Sous-réseaux privés
# ========================================
resource "aws_subnet" "private" {
count = length(var.private_subnet_cidrs)
vpc_id = aws_vpc.main.id
cidr_block = var.private_subnet_cidrs[count.index]
availability_zone = var.availability_zones[count.index]
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-private-${count.index + 1}"
Type = "Private"
})
}
# ========================================
# Elastic IPs pour les NAT Gateways
# ========================================
resource "aws_eip" "nat" {
count = local.nat_gateway_count
domain = "vpc"
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-nat-eip-${count.index + 1}"
})
depends_on = [aws_internet_gateway.main]
}
# ========================================
# NAT Gateways
# ========================================
resource "aws_nat_gateway" "main" {
count = local.nat_gateway_count
allocation_id = aws_eip.nat[count.index].id
subnet_id = aws_subnet.public[count.index].id
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-nat-gw-${count.index + 1}"
})
depends_on = [aws_internet_gateway.main]
}
# ========================================
# Table de routage publique
# ========================================
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-public-rt"
})
}
resource "aws_route_table_association" "public" {
count = length(var.public_subnet_cidrs)
subnet_id = aws_subnet.public[count.index].id
route_table_id = aws_route_table.public.id
}
# ========================================
# Tables de routage privées
# ========================================
resource "aws_route_table" "private" {
count = local.nat_gateway_count > 0 ? length(var.private_subnet_cidrs) : 0
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[var.single_nat_gateway ? 0 : count.index].id
}
tags = merge(local.tags, {
Name = "${var.project_name}-${var.environment}-private-rt-${count.index + 1}"
})
}
resource "aws_route_table_association" "private" {
count = local.nat_gateway_count > 0 ? length(var.private_subnet_cidrs) : 0
subnet_id = aws_subnet.private[count.index].id
route_table_id = aws_route_table.private[count.index].id
}
outputs.tf
# modules/network/outputs.tf
output "vpc_id" {
description = "ID du VPC"
value = aws_vpc.main.id
}
output "vpc_cidr" {
description = "Bloc CIDR du VPC"
value = aws_vpc.main.cidr_block
}
output "public_subnet_ids" {
description = "Liste des IDs des sous-réseaux publics"
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
description = "Liste des IDs des sous-réseaux privés"
value = aws_subnet.private[*].id
}
output "nat_gateway_ips" {
description = "Liste des IPs publiques des NAT Gateways"
value = aws_eip.nat[*].public_ip
}
output "internet_gateway_id" {
description = "ID de la passerelle Internet"
value = aws_internet_gateway.main.id
}
Utilisation du module
# Projet principal - main.tf
terraform {
required_version = ">= 1.0.0"
}
provider "aws" {
region = "eu-west-3"
}
# Environnement de développement
module "network_dev" {
source = "./modules/network"
project_name = "monapp"
environment = "dev"
vpc_cidr = "10.0.0.0/16"
availability_zones = ["eu-west-3a", "eu-west-3b"]
public_subnet_cidrs = ["10.0.1.0/24", "10.0.2.0/24"]
private_subnet_cidrs = ["10.0.10.0/24", "10.0.20.0/24"]
enable_nat_gateway = true
single_nat_gateway = true # Économie en dev
common_tags = {
Team = "backend"
}
}
# Environnement de production
module "network_prod" {
source = "./modules/network"
project_name = "monapp"
environment = "prod"
vpc_cidr = "10.1.0.0/16"
availability_zones = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
public_subnet_cidrs = ["10.1.1.0/24", "10.1.2.0/24", "10.1.3.0/24"]
private_subnet_cidrs = ["10.1.10.0/24", "10.1.20.0/24", "10.1.30.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # Haute dispo en prod
common_tags = {
Team = "backend"
}
}
# Outputs
output "dev_vpc_id" {
value = module.network_dev.vpc_id
}
output "prod_vpc_id" {
value = module.network_prod.vpc_id
}
Bonnes Pratiques pour les Modules Terraform
Pour conclure, voici les bonnes pratiques essentielles à suivre pour créer des modules de qualité professionnelle :
- Un module = une responsabilité. Ne mélangez pas réseau, calcul et base de données dans un seul module. Gardez chaque module focalisé sur un domaine.
- Documentez chaque variable et output. Utilisez systématiquement l'attribut
description. C'est la documentation la plus proche du code. - Fournissez des valeurs par défaut sensées. Facilitez l'adoption du module en proposant des défauts qui fonctionnent pour le cas courant.
- Utilisez la validation des variables. Le bloc
validationpermet de détecter les erreurs tôt, avant leplan. - Versionnez toujours vos modules. Utilisez des tags Git et le versioning sémantique. Ne déployez jamais en production sans version fixée.
- Ajoutez des exemples. Un répertoire
examples/est la meilleure documentation qui soit. Montrez comment utiliser le module dans différents scénarios. - Testez vos modules. Utilisez des outils comme
terratestou le framework natifterraform testpour valider automatiquement le comportement de vos modules. - Évitez les providers dans les modules enfants. Les providers doivent être configurés dans le module racine et passés implicitement aux modules enfants.
Conclusion
Les modules Terraform sont un pilier fondamental de l'Infrastructure as Code à l'échelle. Ils transforment vos configurations monolithiques en composants réutilisables, testables et maintenables. En adoptant une approche modulaire dès le début de vos projets, vous gagnerez un temps considérable sur le long terme et améliorerez la qualité de votre infrastructure.
Commencez par identifier les patterns récurrents dans vos configurations actuelles : réseau, sécurité, calcul, base de données. Chacun de ces domaines est un excellent candidat pour devenir un module. Ensuite, structurez-les selon les conventions que nous avons vues, ajoutez de la documentation et des exemples, et partagez-les avec votre équipe.
Dans notre prochain article, nous explorerons comment utiliser Terraform avec Docker pour gérer vos conteneurs avec l'Infrastructure as Code. Restez connectés !