fr

Terraform : les bonnes pratiques pour structurer, sécuriser et maintenir un projet

Guide complet et sourcé des bonnes pratiques Terraform : structure de projet, gestion du state, versioning, secrets, modules, qualité de code, tests et CI/CD. Chaque recommandation est étayée par la documentation officielle HashiCorp, Google Cloud, AWS et Gruntwork.

Dans les articles précédents sur cloud-init et le setup d’une VM Scaleway, on a utilisé Terraform pour créer de l’infrastructure. Le code fonctionnait, mais on a pris quelques raccourcis : un seul répertoire, un terraform.tfvars avec des secrets en clair, pas de remote state, pas de modules.

Ça marche pour un projet personnel. Ça ne passe pas à l’échelle.

Cet article compile les bonnes pratiques Terraform issues de sources officielles et reconnues : le style guide HashiCorp, les best practices Google Cloud, les guides AWS, et les recommandations de Gruntwork (les créateurs de Terragrunt et Terratest). On couvre la structure du projet, le state, le versioning, les secrets, les modules, la qualité de code, les tests, et le CI/CD.


1. Structure du projet

1.1 Les fichiers de base

Le style guide HashiCorp recommande cette organisation :

FichierContenu
main.tfRessources et data sources
variables.tfDéclarations de variables (par ordre alphabétique)
outputs.tfDéclarations d’outputs (par ordre alphabétique)
locals.tfValeurs locales
providers.tfConfiguration des providers
terraform.tfBloc terraform (versions requises)
backend.tfConfiguration du backend

Pour un petit projet, tout peut rester dans main.tf. Dès que la configuration grossit, on découpe par responsabilité logique — network.tf, storage.tf, compute.tf — avec un nommage « immediately clear to maintainers ». Source : Style Guide

C’est ce qu’on a fait dans l’article Scaleway : ssh.tf, network.tf, storage.tf, instance.tf, cloudinit.tf, outputs.tf, variables.tf, versions.tf. Chaque fichier correspond à une responsabilité claire.

Google Cloud ajoute quelques conventions complémentaires :

  • Les scripts appelés par Terraform vont dans un répertoire scripts/
  • Les fichiers traités par templatefile() vont dans templates/ avec l’extension .tftpl
  • Les fichiers statiques référencés mais non exécutés vont dans files/
  • La documentation supplémentaire va dans docs/

Source : Google Cloud — General style and structure

1.2 Structure multi-environnements

Quand on a plusieurs environnements (dev, staging, prod), deux approches existent :

Approche par répertoires (recommandée par Google Cloud et Gruntwork) :

project/
  modules/
    app/
      main.tf
      variables.tf
      outputs.tf
    database/
  environments/
    dev/
      main.tf
      backend.tf
      terraform.tfvars
    staging/
      main.tf
      backend.tf
      terraform.tfvars
    prod/
      main.tf
      backend.tf
      terraform.tfvars

Chaque environnement a son propre state, ses propres credentials, et sa propre configuration. Une erreur en dev ne peut pas casser la prod. C’est l’isolation par le filesystem. Source : Google Cloud — Root modules

Gruntwork pousse cette logique plus loin en isolant aussi par composant :

prod/
  vpc/
    main.tf
  services/
    webserver-cluster/
      main.tf
  data-stores/
    mysql/
      main.tf
staging/
  vpc/
  services/
  data-stores/
global/
  iam/
  s3/

Chaque composant a son propre state file. Le blast radius d’une erreur est limité à un seul composant dans un seul environnement. Source : Gruntwork — How to manage Terraform state

Approche par workspaces (voir section 8.1 pour les détails et limitations) : un seul répertoire, plusieurs workspaces Terraform. Plus simple, mais moins de garanties d’isolation.

1.3 Taille du state

Google Cloud recommande de ne pas dépasser 100 ressources par state (idéalement quelques dizaines) pour éviter des terraform plan trop lents. HashiCorp suggère un maximum de 5 000 objets par workspace. Au-delà, chaque plan nécessite de rafraîchir l’état de toutes les ressources via les APIs du provider — ça peut prendre 10 à 15 minutes. Source : Google Cloud — Root modules, Source : HashiCorp — Workspace size

1.4 Ce qu’on commit, ce qu’on ignore

Le style guide est explicite :

Toujours committer :

  • Tous les fichiers .tf
  • .terraform.lock.hcl (le lock file des dépendances)
  • .gitignore
  • README.md

Ne jamais committer :

  • terraform.tfstate et terraform.tfstate.*
  • .terraform.tfstate.lock.info
  • Le répertoire .terraform/
  • Les fichiers de plan (créés avec -out)
  • Les fichiers .tfvars contenant des secrets

Source : Style Guide

Le .gitignore recommandé par GitHub pour Terraform ignore par défaut tous les *.tfvars. Source : HashiCorp — Sensitive variables


2. Gestion du state

Le state est le cœur de Terraform. C’est le fichier qui fait le lien entre votre configuration HCL et les ressources réelles chez le provider. Mal géré, il devient un problème de sécurité et de collaboration.

2.1 Remote state

Par défaut, le state est stocké localement dans terraform.tfstate. C’est l’erreur n°1 des débutants selon Pipetail : pas de partage entre membres de l’équipe, pas de verrouillage, pas de chiffrement, pas de backup.

La solution : un backend distant. Le bloc backend se configure dans le bloc terraform :

terraform {
  backend "s3" {
    bucket       = "my-terraform-state"
    key          = "prod/network/terraform.tfstate"
    region       = "eu-west-1"
    encrypt      = true
    use_lockfile = true
  }
}

Points importants :

  • Un seul backend par configuration — « A configuration can only provide one backend block »
  • Le bloc backend ne supporte pas les variables, les locals, ni les data sources. C’est une limitation volontaire (le backend doit être résolu avant toute évaluation). Pour paramétrer le backend, on utilise la configuration partielle via -backend-config=PATH au moment du terraform init
  • HashiCorp recommande de passer les credentials du backend via des variables d’environnement

Convention de nommage pour les fichiers de config partielle : *.backendname.tfbackend (ex. config.s3.tfbackend).

Source : Backend block, Source : Backend S3

2.2 Verrouillage du state

Le verrouillage empêche deux terraform apply concurrents de corrompre le state :

« If supported by your backend, Terraform will lock your state for all operations that could write state. This prevents others from acquiring the lock and potentially corrupting your state. » Source : State locking

Si le verrouillage échoue, Terraform arrête l’exécution plutôt que de continuer sans verrou. Le flag -lock=false peut le désactiver, mais HashiCorp le déconseille.

Pour le backend S3, deux mécanismes existent :

  • Verrouillage natif S3 (recommandé) : use_lockfile = true — utilise un fichier .tflock dans le bucket
  • DynamoDB (déprécié) : dynamodb_table avec une partition key LockID (String)

2.3 Chiffrement du state

Le state contient des valeurs sensibles en clair (mots de passe, tokens, clés API). Le chiffrement au repos est indispensable.

Pour S3, trois options :

ModeConfigurationDétail
SSE-S3encrypt = trueChiffrement par défaut avec les clés gérées par S3
SSE-KMSkms_key_id = "arn:..."Chiffrement avec une clé KMS dédiée (nécessite kms:Encrypt, kms:Decrypt, kms:GenerateDataKey)
SSE-Csse_customer_keyClé fournie par le client (base64, 256 bits)

HashiCorp recommande aussi d’activer le versioning du bucket S3 pour pouvoir récupérer un state en cas d’erreur. Source : Backend S3

Google Cloud recommande de restreindre l’accès au bucket de state : « Make sure that only the build system and highly privileged administrators can access the bucket. » Source : Google Cloud — Security

2.4 terraform_remote_state : avec prudence

La data source terraform_remote_state permet de lire les outputs d’un autre state. C’est pratique pour partager des informations entre composants (l’ID du VPC, l’ARN d’un rôle IAM, etc.).

data "terraform_remote_state" "vpc" {
  backend = "s3"
  config = {
    bucket = "my-terraform-state"
    key    = "prod/vpc/terraform.tfstate"
    region = "eu-west-1"
  }
}

resource "aws_instance" "web" {
  subnet_id = data.terraform_remote_state.vpc.outputs.subnet_id
}

Mais HashiCorp met en garde : « Any user or server which has enough access to read the root module output values will also always have access to the full state snapshot data. » Autrement dit, quiconque peut lire un output peut potentiellement lire tout le state.

L’alternative recommandée : partager les données via un store dédié (SSM Parameter Store, Consul, un config map Kubernetes) plutôt que via le state directement. Source : terraform_remote_state


3. Versioning et gestion des providers

3.1 Contraintes de version

Terraform utilise un système de contraintes de version flexible :

OpérateurSignificationExemple
= (ou rien)Version exacte= 1.0.0
!=Exclut une version!= 1.0.0
>, >=, <, <=Comparaison>= 1.2.0, < 2.0.0
~>Pessimiste — seul le dernier composant peut incrémenter~> 1.0.4 autorise 1.0.5 à 1.0.x, bloque 1.1.0

L’opérateur ~> est le plus utilisé. ~> 1.1 autorise 1.2, 1.10, mais bloque 2.0. ~> 1.1.0 autorise 1.1.1 à 1.1.x, mais bloque 1.2.0.

Bonnes pratiques selon le contexte :

  • Root modules : utiliser ~> pour borner les versions des providers (ex. ~> 5.34.0)
  • Modules réutilisables : utiliser uniquement un minimum (>= 0.12.0), sans borne supérieure — « Do not use ~> for modules you intend to reuse across many configurations » car cela forcerait tous les consommateurs à upgrader simultanément

Source : Version Constraints

3.2 Pinning des versions

# terraform.tf
terraform {
  required_version = ">= 1.7.0"

  required_providers {
    scaleway = {
      source  = "scaleway/scaleway"
      version = "~> 2.41.0"
    }
    cloudinit = {
      source  = "hashicorp/cloudinit"
      version = "~> 2.3"
    }
  }
}

On retrouve cette structure dans notre configuration Scaleway. Trois éléments sont pinnés :

  1. La version de Terraform (required_version) — empêche quelqu’un d’utiliser une version incompatible
  2. Les providers (required_providers) — fixe la source et la version de chaque provider
  3. Les modules (dans les appels de module) — fixe la version du module consommé

Ne pas pinner ses versions est l’un des 10 erreurs les plus courantes identifiées par Pipetail : un terraform init peut silencieusement télécharger une version majeure incompatible.

3.3 Le fichier .terraform.lock.hcl

Ce fichier trace les versions exactes des providers sélectionnés par Terraform. Il fonctionne sur un modèle de « trust on first use » : la première installation enregistre les checksums, les suivantes les vérifient.

« You should include this file in your version control repository so that you can discuss potential changes to your external dependencies via code review. » Source : Dependency Lock File

Règles clés :

  • Toujours committer .terraform.lock.hcl
  • Ne jamais l’éditer manuellement — utiliser terraform init -upgrade
  • Pour les équipes multi-plateformes (macOS + Linux), utiliser terraform providers lock pour pré-calculer les checksums de chaque OS

4. Variables et conventions de nommage

4.1 Conventions de nommage

Le style guide HashiCorp est clair :

« Use nouns for resource names and do not include the resource type in the name. Use underscores to separate multiple words in names. »

# Mauvais
resource "aws_instance" "webAPI-aws-instance" { ... }

# Bon
resource "aws_instance" "web_api" { ... }

Règles complémentaires de Google Cloud :

  • Pour une ressource unique de son type, utiliser le nom main (ex. resource "google_compute_instance" "main")
  • Garder les noms de ressources au singulier
  • Nommer les variables numériques avec leur unité : disk_size_gb, memory_size_mb
  • Donner aux booléens des noms positifs : enable_external_access plutôt que disable_external_access

4.2 Déclaration des variables

L’ordre recommandé des attributs dans un bloc variable :

variable "instance_type" {
  description = "Type commercial de l'instance Scaleway"
  type        = string
  default     = "DEV1-S"

  validation {
    condition     = contains(["DEV1-S", "DEV1-M", "DEV1-L"], var.instance_type)
    error_message = "Le type d'instance doit être DEV1-S, DEV1-M ou DEV1-L."
  }
}

Ordre : descriptiontypedefaultsensitivevalidation. Source : Style Guide

Quelques règles importantes :

  • Toujours inclure type et description — même quand ça paraît évident. La description est utilisée pour la génération automatique de documentation
  • Définir des default raisonnables pour les variables optionnelles
  • sensitive = true pour les mots de passe et clés privées (voir section 5)
  • Pas de default pour les secrets — forcer l’utilisateur à fournir la valeur explicitement
  • nullable = false pour les variables qui ne doivent jamais être null

Source : Input Variables

4.3 Les blocs de validation

Les blocs validation sont exécutés immédiatement, avant la génération du plan. C’est une première ligne de défense contre les erreurs de configuration :

variable "environment" {
  type        = string
  description = "Environnement de déploiement"

  validation {
    condition     = contains(["dev", "staging", "prod"], var.environment)
    error_message = "L'environnement doit être dev, staging ou prod."
  }
}

variable "allowed_ssh_cidrs" {
  type        = list(string)
  description = "CIDRs autorisés pour SSH"

  validation {
    condition     = length(var.allowed_ssh_cidrs) > 0
    error_message = "Au moins un CIDR doit être autorisé pour SSH."
  }
}

Pour des validations plus avancées (au niveau des ressources), Terraform offre aussi precondition et postcondition dans les blocs lifecycle :

data "aws_vpc" "app" {
  id = var.vpc_id

  lifecycle {
    postcondition {
      condition     = self.enable_dns_support == true
      error_message = "Le VPC doit avoir le support DNS activé."
    }
  }
}

Source : Custom conditions

4.4 locals vs variables

variablelocal
VisibilitéAPI externe du moduleInterne au module uniquement
SourceL’appelant fournit la valeurCalculé dans le module
Usage typeParamètres configurablesExpressions réutilisées, valeurs dérivées

Les locals sont utiles pour factoriser des expressions complexes et leur donner un nom lisible :

locals {
  name_prefix = "${var.project}-${var.environment}"
  common_tags = {
    Project     = var.project
    Environment = var.environment
    ManagedBy   = "terraform"
  }
}

Mais le style guide met en garde : « locals can obscure where values originate, potentially reducing readability ». À utiliser avec modération. Source : Local Values

4.5 Ordre de précédence des variables

Quand une même variable est définie à plusieurs endroits, Terraform applique cet ordre (du plus faible au plus fort) :

  1. Variables d’environnement (TF_VAR_*)
  2. terraform.tfvars
  3. terraform.tfvars.json
  4. Fichiers *.auto.tfvars (par ordre lexicographique)
  5. Flags -var et -var-file en ligne de commande

Source : Input Variables


5. Gestion des secrets

C’est le sujet le plus critique et le plus souvent mal géré. HashiCorp identifie la gestion des secrets comme l’une de ses 5 pratiques de sécurité fondamentales.

5.1 Le problème : les secrets dans le state

Terraform stocke tout dans le state, y compris les valeurs sensibles, en clair. Ce n’est pas un bug, c’est le fonctionnement normal : Terraform a besoin de connaître l’état complet des ressources pour calculer les différences.

« Terraform stores values with the sensitive argument in both state and plan files, and anyone who can access those files can access your sensitive values. » Source : HashiCorp — Sensitive variables

Un simple grep "password" terraform.tfstate suffit à retrouver les credentials. Google Cloud avertit que de nombreuses ressources Terraform stockent des valeurs sensibles en clair dans le state, notamment tls_private_key et google_service_account_key. Source : Google Cloud — Security

5.2 sensitive = true : nécessaire mais insuffisant

Déclarer une variable comme sensitive masque sa valeur dans les outputs CLI (elle apparaît comme (sensitive value)), mais ne protège pas le state :

variable "ghcr_pat" {
  description = "Personal Access Token GitHub (scope: read:packages)"
  type        = string
  sensitive   = true  # masqué dans les logs, mais en clair dans le state
}

C’est ce qu’on a utilisé dans notre configuration Scaleway. Le PAT GitHub est marqué sensitive, donc il n’apparaît pas dans le terraform plan. Mais il reste en clair dans le state et dans le user-data de l’instance.

5.3 Les valeurs éphémères (Terraform 1.10+)

Terraform 1.10 (novembre 2024) a introduit les valeurs éphémères : « available at runtime, but Terraform omits them from state and plan files entirely. » C’est une avancée majeure.

variable "db_password" {
  type      = string
  ephemeral = true  # jamais stocké dans le state ni le plan
}

Terraform 1.11 a ajouté les write-only arguments : des attributs de ressource qui ne peuvent qu’être écrits, jamais lus, et qui ne sont pas non plus stockés dans le state.

Source : Terraform 1.10 ephemeral values, Source : Terraform 1.11 write-only arguments

5.4 Les variables d’environnement TF_VAR_*

C’est la méthode la plus sûre pour passer des secrets : les valeurs restent en mémoire et ne touchent jamais le disque.

export TF_VAR_ghcr_pat="ghp_xxxxxxxxxxxx"
export TF_VAR_ssh_public_key="ssh-ed25519 AAAA..."
terraform plan

Les variables TF_VAR_* sont au bas de la chaîne de précédence : un .tfvars ou un -var les surcharge. C’est la méthode privilégiée en CI/CD car elle s’intègre naturellement avec les systèmes de secrets (GitHub Secrets, Vault, etc.). Source : Spacelift — Terraform .tfvars

5.5 Les fichiers .tfvars

Pour le développement local, un fichier terraform.tfvars (ou secrets.tfvars) est pratique :

# terraform.tfvars — NE PAS COMMITTER
ghcr_pat             = "ghp_xxxxxxxxxxxx"
ssh_public_key       = "ssh-ed25519 AAAA..."
ssh_public_key_deploy = "ssh-ed25519 AAAA..."

Rappel : le .gitignore recommandé par GitHub ignore tous les *.tfvars par défaut. Si vous utilisez un fichier terraform.tfvars.example (avec des valeurs factices) pour documenter les variables attendues, nommez-le avec l’extension .example, pas .tfvars.

5.6 Intégration avec un gestionnaire de secrets

La meilleure approche est de récupérer les secrets au runtime depuis un gestionnaire dédié, plutôt que de les passer via des variables.

HashiCorp Vault :

data "vault_aws_access_credentials" "creds" {
  backend = "aws"
  role    = "deploy"
  type    = "sts"
}

provider "aws" {
  access_key = data.vault_aws_access_credentials.creds.access_key
  secret_key = data.vault_aws_access_credentials.creds.secret_key
  token      = data.vault_aws_access_credentials.creds.security_token
}

Le provider Vault génère des credentials temporaires (TTL de 20 minutes par défaut) via le secrets engine AWS. Les credentials sont automatiquement révoqués après expiration. Attention : « Terraform has no mechanism to redact or protect secrets returned via data sources, so secrets read via this provider will be persisted into the Terraform state. » Source : Vault Provider

AWS Secrets Manager :

data "aws_secretsmanager_secret_version" "db_password" {
  secret_id = "prod/database/password"
}

resource "aws_db_instance" "main" {
  password = jsondecode(
    data.aws_secretsmanager_secret_version.db_password.secret_string
  )["password"]
}

Source : AWS — Using Secrets Manager and Terraform

Dans notre article Scaleway, on a montré une approche similaire avec Scaleway Secret Manager : le PAT GitHub est stocké dans un secret, et l’instance le récupère au runtime via l’API.

5.7 La défense en profondeur

Aucune mesure seule ne suffit. La stratégie complète combine :

  1. sensitive = true sur les variables et outputs → masque les valeurs dans la CLI
  2. Variables d’environnement (TF_VAR_*) → les secrets ne touchent pas le disque
  3. Gestionnaire de secrets (Vault, AWS SM, Scaleway SM) → les secrets ne sont pas dans le code
  4. State chiffré en remote → les secrets en clair dans le state sont protégés au repos
  5. Accès restreint au state → seul le pipeline CI/CD et les admins y accèdent
  6. Valeurs éphémères (Terraform 1.10+) → les secrets ne sont pas du tout dans le state

Source : HashiCorp — 5 foundational practices


6. Modules

6.1 Quand créer un module ?

HashiCorp recommande de créer des modules qui « raise the level of abstraction by describing a new concept in your architecture ». Un bon test : si le nom du module est identique au type de ressource principale qu’il contient, il manque probablement d’abstraction. Utilisez la ressource directement. Source : Creating Modules

Gruntwork va plus loin : « Large modules should be considered harmful » — les gros modules sont lents, difficiles à tester, à revoir en code review, et fragiles. Source : Gruntwork — Reusable infrastructure with Terraform modules

Trois principes de scoping pour concevoir un module (recommandation officielle HashiCorp) :

  1. Encapsulation : regrouper l’infrastructure qui est toujours déployée ensemble
  2. Privilèges : respecter les frontières de permissions — ne pas mélanger des ressources nécessitant des niveaux d’accès différents
  3. Volatilité : séparer l’infrastructure à longue durée de vie (bases de données) de celle à courte durée de vie (serveurs d’application)

Source : Module creation — recommended pattern

6.2 Structure d’un module

La structure standard recommandée :

module-name/
  README.md             # Documentation obligatoire
  main.tf               # Point d'entrée principal
  variables.tf          # Toutes les variables
  outputs.tf            # Tous les outputs
  LICENSE               # Recommandé pour les modules publics
  modules/              # Sous-modules
    nested-a/
      README.md         # Un README = module utilisable en externe
      main.tf
      variables.tf
      outputs.tf
    nested-b/           # Pas de README = module interne uniquement
  examples/             # Exemples d'utilisation
    basic/
      main.tf
    complete/
      main.tf

Les sous-modules sous modules/ avec un README.md sont considérés comme utilisables par des consommateurs externes. Ceux sans README sont internes. Les exemples dans examples/ doivent utiliser des sources externes (pas des chemins relatifs) pour refléter l’usage réel.

Gruntwork ajoute un fichier dependencies.tf optionnel pour lister les ressources qui doivent pré-exister. Source : Gruntwork — Terraform Style Guide

6.3 Sources de modules

Terraform supporte plusieurs types de sources :

# Registry (public ou privé)
module "vpc" {
  source  = "terraform-aws-modules/vpc/aws"
  version = "5.0.0"
}

# Chemin local (doit commencer par ./ ou ../)
module "network" {
  source = "./modules/network"
}

# Git (avec pinning via ref)
module "app" {
  source = "git::https://github.com/org/modules.git//app?ref=v1.2.0"
}

Pour les modules Git, le paramètre ref accepte tout ce que git checkout accepte : tag, branche, SHA-1. Source : Module Sources

6.4 Bonnes pratiques de conception

  • Viser 80% des use cases avec les inputs par défaut, pas les cas limites
  • Maximiser les outputs même sans usage immédiat — les consommateurs chaîneront les outputs d’un module vers les inputs d’un autre
  • Les inputs obligatoires (sans default) représentent des choix de configuration délibérés
  • Les inputs optionnels ont des defaults sensés, adaptés à la majorité des cas
  • Garder l’arbre de modules plat : « Keep the module tree relatively flat and use module composition as an alternative to a deeply-nested tree of modules. » Ne pas dépasser deux niveaux d’imbrication

Source : Module Composition

Convention de nommage pour publier sur le registry : terraform-<PROVIDER>-<NAME> (ex. terraform-aws-vpc). Source : Modules overview


7. Qualité de code et outillage

7.1 terraform fmt

Le formateur intégré applique la mise en forme canonique du HCL : indentation cohérente, alignement des =, suppression des whitespace superflus.

terraform fmt             # formate le répertoire courant
terraform fmt -check      # vérifie sans modifier (utile en CI)
terraform fmt -diff       # affiche les différences
terraform fmt -recursive  # formate les sous-répertoires

Le style guide recommande de l’exécuter avant chaque commit. Source : Style Guide

7.2 terraform validate

Vérifie que la configuration est syntaxiquement valide et internement cohérente. Ne contacte pas le provider (pas de vérification API) et n’évalue pas le state existant.

terraform init      # requis avant validate
terraform validate

7.3 TFLint

Un linter open source qui va plus loin que terraform validate. Il détecte :

  • Les types d’instance invalides (ex. un type AWS qui n’existe pas)
  • La syntaxe dépréciée
  • Les violations de bonnes pratiques
  • Les problèmes spécifiques aux providers (via des plugins)

Configuration .tflint.hcl :

plugin "aws" {
  enabled = true
  region  = "eu-west-1"
}

rule "terraform_required_version" {
  enabled = true
}

Plugins disponibles : AWS (tflint-ruleset-aws), Azure (tflint-ruleset-azurerm), GCP (tflint-ruleset-google).

tflint --init    # installe les plugins
tflint           # exécute le linting

Source : TFLint GitHub

7.4 Pre-commit hooks

Le projet pre-commit-terraform d’Anton Babenko fournit une collection de hooks Git pour Terraform. Configuration .pre-commit-config.yaml :

repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: v1.96.1
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_docs
      - id: terraform_trivy # scan de sécurité (remplace tfsec)
      - id: terraform_checkov # analyse statique de sécurité
pre-commit install    # installe les hooks
pre-commit run -a     # exécute tous les hooks sur tous les fichiers

Les hooks disponibles incluent aussi infracost_breakdown (estimation des coûts) et terragrunt_fmt.

7.5 terraform-docs

Génère automatiquement la documentation d’un module à partir des variables, outputs, providers et ressources.

terraform-docs markdown table ./modules/vpc

Configuration .terraform-docs.yml :

formatter: "markdown table"
output:
  file: "README.md"
  mode: "inject" # injecte entre les marqueurs

sections:
  show:
    - header
    - inputs
    - outputs
    - providers
    - resources

Le mode inject insère la documentation entre des marqueurs dans le README :

<!-- BEGIN_TF_DOCS -->

(contenu auto-généré)

<!-- END_TF_DOCS -->

Combiné avec les pre-commit hooks, la documentation reste synchronisée avec le code automatiquement. Source : terraform-docs

7.6 Scan de sécurité

Checkov : scanner open source avec plus de 1 000 politiques de sécurité intégrées couvrant CIS, NIST, PCI-DSS, HIPAA, SOC2 et GDPR. Il analyse le code HCL (pas le plan) et détecte les stockages non chiffrés, les permissions excessives, les security groups ouverts, etc.

checkov -d .
checkov --framework terraform --check CKV_AWS_18  # check spécifique

tfsec (maintenant intégré dans Trivy) : analyse statique spécifiquement conçue pour Terraform. tfsec n’est pas officiellement déprécié mais son développement est intégré dans Trivy (Aqua Security). Source : tfsec GitHub

La pyramide de tests recommandée :

              ┌─────────────────┐
              │ End-to-end      │  ← Plus lent, plus cher
              ├─────────────────┤
              │ Intégration     │  terraform test (apply)
              ├─────────────────┤
              │ Plan-based      │  terraform test (plan)
              ├─────────────────┤
              │ Analyse statique│  ← Plus rapide, moins cher
              └─────────────────┘
                validate, tflint,
                checkov, trivy

8. Patterns avancés

8.1 Workspaces : quand les utiliser, quand les éviter

Un workspace Terraform est une instance séparée du state dans le même répertoire de travail. Tous les workspaces partagent le même code et le même backend.

terraform workspace new staging
terraform workspace select staging
terraform workspace list

Le nom du workspace est accessible via terraform.workspace :

resource "aws_instance" "web" {
  count = terraform.workspace == "prod" ? 3 : 1
}

Les workspaces sont adaptés pour :

  • Les environnements éphémères (feature branches, tests, PR previews)
  • Les petits projets où les environnements sont quasi identiques
  • Le prototypage local

Les workspaces ne sont PAS adaptés pour (avertissement officiel HashiCorp) :

« Workspaces are not appropriate for system decomposition or deployments requiring separate credentials and access controls. » Source : Workspaces

Limitations concrètes :

  • Pas d’isolation des credentials : tous les workspaces partagent le même backend et la même authentification
  • Versions verrouillées : une mise à jour de module affecte tous les workspaces simultanément
  • Risque d’erreur : un terraform apply dans le mauvais workspace peut être désastreux

Pour des environnements permanents (dev/staging/prod), préférer l’isolation par répertoires (section 1.2).

8.2 Terragrunt : DRY multi-environnements

Terragrunt (de Gruntwork) est un wrapper autour de Terraform qui résout plusieurs limitations :

  1. Le backend ne supporte pas les variables : Terragrunt permet de définir la configuration du backend une seule fois et de l’hériter
  2. La duplication de code entre environnements : Terragrunt référence des modules partagés avec des inputs spécifiques à l’environnement
  3. Les dépendances entre modules : Terragrunt modélise un graphe de dépendances et exécute les modules dans le bon ordre

L’idée : séparer en deux repositories — un pour les modules (le code réutilisable) et un pour la configuration live (les valeurs par environnement) :

# live/prod/app/terragrunt.hcl
terraform {
  source = "git::git@github.com:org/modules.git//app?ref=v1.0.0"
}

inputs = {
  instance_count = 10
  instance_type  = "m8g.large"
}
# live/staging/app/terragrunt.hcl
terraform {
  source = "git::git@github.com:org/modules.git//app?ref=v1.1.0"
}

inputs = {
  instance_count = 2
  instance_type  = "t4g.micro"
}

Chaque environnement peut utiliser une version différente du module (via ?ref=). La promotion est un changement de tag dans le fichier terragrunt.hcl. Le rollback est un retour au tag précédent.

AspectWorkspacesTerragrunt
Isolation des credentialsNonOui (par environnement)
Versioning indépendant des modulesNonOui (via ?ref=)
Gestion des dépendancesManuelleAutomatique
Blast radiusPartagéIsolé par composant
Courbe d’apprentissageFaibleOutil supplémentaire

Quand ne pas utiliser Terragrunt : petit projet avec un seul environnement, équipe pas prête pour un outil supplémentaire, ou déjà sur HCP Terraform qui fournit sa propre orchestration.

Source : Terragrunt — Keep your Terraform code DRY

8.3 Import, refactoring et migration

import blocks (Terraform 1.5+) : importation déclarative, versionnée, compatible CI/CD :

import {
  to = aws_s3_bucket.this
  id = "my-existing-bucket"
}

resource "aws_s3_bucket" "this" {
  bucket = "my-existing-bucket"
}

Avec terraform plan -generate-config-out=generated.tf, Terraform génère automatiquement le bloc resource correspondant. Source : import block

moved blocks (Terraform 1.1+) : refactoring sans destruction :

moved {
  from = aws_instance.web
  to   = module.web.aws_instance.this
}

Permet de renommer des ressources, de les déplacer dans un module, ou de passer de count à for_each sans recréer l’infrastructure. HashiCorp recommande de conserver les blocs moved pendant au moins un cycle de release. Source : moved block

removed blocks (Terraform 1.7+) : arrêter de gérer une ressource sans la détruire :

removed {
  from = aws_instance.legacy

  lifecycle {
    destroy = false  # la ressource continue d'exister
  }
}

Alternative déclarative et versionnée à terraform state rm. Source : removed block


9. Tests

9.1 terraform test (natif, Terraform 1.6+)

Terraform 1.6 a introduit un framework de test natif utilisant des fichiers .tftest.hcl :

# tests/instance.tftest.hcl
variables {
  instance_type = "DEV1-S"
}

run "valid_instance_type" {
  command = plan    # test unitaire : pas de ressource créée

  assert {
    condition     = scaleway_instance_server.main.type == "DEV1-S"
    error_message = "Le type d'instance ne correspond pas"
  }
}

run "create_and_verify" {
  command = apply   # test d'intégration : crée de vraies ressources

  assert {
    condition     = scaleway_instance_server.main.id != ""
    error_message = "L'instance n'a pas été créée"
  }
}

Deux modes :

  • command = plan : test unitaire rapide, aucune ressource créée
  • command = apply : test d’intégration, crée de vraies ressources (et les détruit après)
terraform test                     # exécute tous les tests
terraform test -filter=tests/s3    # filtre par fichier

Source : Tests

9.2 Terratest

Terratest (Gruntwork) est une bibliothèque Go pour écrire des tests d’infrastructure. Contrairement à terraform test, Terratest interroge les APIs du provider pour vérifier l’état réel des ressources, pas seulement le state Terraform.

func TestTerraformExample(t *testing.T) {
    terraformOptions := &terraform.Options{
        TerraformDir: "../examples/basic",
    }
    defer terraform.Destroy(t, terraformOptions)
    terraform.InitAndApply(t, terraformOptions)

    output := terraform.Output(t, terraformOptions, "instance_id")
    assert.NotEmpty(t, output)
}

Plus de confiance dans les résultats, mais tests plus lents et nécessite Go. Source : Terratest


10. Sécurité avancée

10.1 Principe du moindre privilège

Le compte de service utilisé par Terraform pour interagir avec le provider doit avoir le minimum de permissions nécessaires :

  • AWS : préférer les IAM Roles aux IAM Users pour la rotation automatique des credentials. Utiliser assume_role dans la configuration du provider. Utiliser IAM Access Analyzer pour identifier et supprimer les permissions inutilisées. Source : AWS — Security best practices
  • Google Cloud : « Using the default service account is not recommended, because by default the default service account is highly privileged. » Créer des comptes de service dédiés avec des permissions limitées. Utiliser l’impersonation plutôt que des clés de service account. Source : Google Cloud — Security

10.2 OIDC : authentification sans secrets statiques

OpenID Connect (OIDC) élimine les credentials statiques en CI/CD. GitHub Actions s’authentifie directement auprès du cloud provider avec des tokens éphémères :

# .github/workflows/terraform.yml
permissions:
  id-token: write
  contents: read

steps:
  - uses: aws-actions/configure-aws-credentials@v2
    with:
      role-to-assume: arn:aws:iam::111122223333:role/terraform-role
      aws-region: eu-west-1

Le workflow crée un JWT unique, l’échange contre des credentials temporaires (durée par défaut : 1 heure), et les utilise pour le provider AWS. Aucun secret stocké dans GitHub Secrets ni dans le code. Source : GitHub — OIDC for AWS

10.3 Policy as Code

Sentinel (HashiCorp, intégré à Terraform Cloud/Enterprise) : évalue les policies après terraform plan et avant terraform apply. Trois niveaux d’enforcement :

  • Advisory : warning seulement
  • Soft-mandatory : bloquant sauf exception configurée
  • Hard-mandatory : bloquant sans exception possible

Source : Sentinel policies

Open Policy Agent (OPA) : policies écrites en Rego, évaluées contre le plan Terraform converti en JSON :

terraform plan -out=tfplan.binary
terraform show -json tfplan.binary > tfplan.json
conftest test tfplan.json

Source : OPA — Terraform


11. CI/CD

11.1 Le workflow standard

Le pattern recommandé pour Terraform en CI/CD :

  1. Pull requestterraform fmt -check + terraform validate + terraform plan + scan de sécurité (Checkov/Trivy) → le plan est posté en commentaire de la PR
  2. Review → un humain vérifie le plan
  3. Mergeterraform apply avec le plan sauvegardé en artefact (pour garantir que ce qui est appliqué est exactement ce qui a été revu)

Source : HashiCorp — Automate Terraform with GitHub Actions

11.2 HCP Terraform (ex-Terraform Cloud)

La plateforme SaaS de HashiCorp pour gérer Terraform en équipe. Elle fournit :

  • Remote operations : Terraform s’exécute sur des VMs jetables dans l’infrastructure HashiCorp
  • Dynamic provider credentials : basées sur OIDC, des credentials éphémères sont générées pour chaque run
  • Workspace management : RBAC, triggers inter-workspaces, intégration VCS
  • Sentinel : policy as code intégrée
  • Cost estimation : estimation des coûts avant apply

Source : HCP Terraform — Plans and Features

11.3 Atlantis

Atlantis est un outil open source d’automatisation des pull requests Terraform, auto-hébergé. Son workflow :

  1. Le développeur ouvre une PR avec des changements Terraform
  2. Atlantis exécute automatiquement terraform plan et poste le résultat en commentaire
  3. L’équipe review et discute ; chaque push déclenche un nouveau plan
  4. Un reviewer autorisé tape atlantis apply dans un commentaire
  5. Atlantis verrouille le workspace, exécute terraform apply, commente le résultat
  6. La PR est mergée, Atlantis libère le verrou

Source : Atlantis

11.4 Détection de drift

Un workflow GitHub Actions planifié détecte le drift de configuration :

on:
  schedule:
    - cron: "0 8 * * 1-5" # tous les jours ouvrés à 8h

jobs:
  drift-detection:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: hashicorp/setup-terraform@v3
      - run: terraform init
      - run: terraform plan -detailed-exitcode
        # exit code 2 = des changements détectés (drift)

Si terraform plan retourne un exit code 2, des changements ont été faits en dehors de Terraform. On peut créer automatiquement une issue GitHub pour traçabilité. Source : DevOps Tooling — Terraform GitHub Actions Guide


12. Anti-patterns à éviter

Le monolithe de state (« Terralith »)

Un seul root module gérant toute l’infrastructure d’un projet. Conséquences : plans lents (10-15 min), blast radius maximal, un seul pipeline peut modifier le state à la fois, couplage fort entre composants sans rapport. Source : Masterpoint — The Terralith

Versions non pinnées

Ne pas pinner les providers ni la version de Terraform expose aux breaking changes silencieuses. Toujours utiliser required_version, required_providers, et version sur les appels de modules.

Secrets en clair

Stocker des mots de passe dans les .tf, les .tfvars commités, ou le state non chiffré. Voir section 5 pour les solutions.

Modules wrappers

Des modules qui exposent chaque attribut de la ressource sous-jacente comme variable. Ils n’ajoutent aucune abstraction et compliquent la maintenance. Si le nom du module est le nom du type de ressource, il n’a probablement pas lieu d’exister.

terraform.workspace comme logique métier

Utiliser terraform.workspace dans des conditionnels pour différencier les environnements crée des différences implicites, difficiles à tracer et à auditer. Préférer des variables explicites.

Pas de plan avant apply

Toujours exécuter terraform plan et le relire avant d’appliquer. En CI/CD, sauvegarder le plan comme artefact et l’appliquer — jamais de terraform apply sans plan préalable.

Source : Pipetail — 10 most common mistakes


Récapitulatif

PratiqueCe qu’il faut faire
StructureUn fichier par responsabilité, un répertoire par environnement
StateRemote, chiffré, verrouillé, accès restreint
VersioningPinner Terraform, les providers et les modules. Committer le lock file
Secretssensitive = true + TF_VAR_* + gestionnaire de secrets + state chiffré
ModulesCréer quand il y a une vraie abstraction. Garder l’arbre plat
Qualitéfmt + validate + TFLint + pre-commit + terraform-docs
Teststerraform test (plan + apply) pour les modules critiques
SécuritéMoindre privilège, OIDC, scan statique, policy as code
CI/CDPlan sur PR, review humaine, apply sur merge, détection de drift

Sources principales

Documentation officielle HashiCorp

Blog HashiCorp

Google Cloud

AWS

Gruntwork

Outils

Communauté