Apprenez Terraform pour gérer votre infrastructure AWS as Code : VPC, EC2, RDS, modules, state remote S3 et bonnes pratiques DevOps en production.
Pourquoi Infrastructure as Code ?
Cliquer dans la console AWS pour créer un VPC, deux subnets, une instance EC2 et une base RDS prend dix minutes la première fois — et trois heures la deuxième, quand vous tentez de reproduire exactement le même setup en staging. L'Infrastructure as Code (IaC) résout ce problème : vous décrivez votre infra dans des fichiers texte versionnés sur Git, et un outil l'instancie de manière reproductible.
Terraform, créé par HashiCorp en 2014, est devenu le standard de facto. Sa syntaxe HCL (HashiCorp Configuration Language) est lisible, son ecosystem de providers couvre AWS, Azure, GCP, Kubernetes, GitHub, Datadog, Cloudflare et 3000+ services, et sa gestion d'état permet la collaboration en équipe.
Comparaison des outils IaC
| Critère | Terraform | CloudFormation | Pulumi |
|---|---|---|---|
| Langage | HCL (déclaratif) | YAML / JSON | TypeScript, Python, Go, C# |
| Cloud supportés | Multi-cloud (3000+ providers) | AWS uniquement | Multi-cloud |
| State management | Explicite (local ou remote) | Géré par AWS (transparent) | Pulumi Cloud ou self-hosted |
| Communauté modules | Très large (Terraform Registry) | Modérée (AWS QuickStarts) | En croissance |
| Courbe d'apprentissage | Moyenne (HCL spécifique) | Faible si AWS connu | Faible si dev TS/Python |
| Coût | Gratuit (OSS) + Cloud payant | Gratuit | Gratuit OSS, Pulumi Cloud payant |
Les bénéfices concrets en équipe
- Reproductibilité : un même fichier
main.tfcrée une infra identique en dev, staging et prod. - Versioning Git : chaque changement passe par une Pull Request, review code et historique complet.
- Drift detection :
terraform plandétecte les modifications manuelles non autorisées dans la console. - Disaster recovery : une région AWS indisponible ?
terraform applydans une autre région et vous repartez en quelques minutes. - Documentation auto : le code est la documentation de l'infrastructure.
Installer Terraform et AWS CLI
Installation Terraform (Linux / macOS / Windows)
# Linux (Ubuntu / Debian)
wget -O- https://apt.releases.hashicorp.com/gpg | \
sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] \
https://apt.releases.hashicorp.com $(lsb_release -cs) main" | \
sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update && sudo apt install terraform
# macOS (Homebrew)
brew tap hashicorp/tap
brew install hashicorp/tap/terraform
# Windows (Chocolatey)
choco install terraform
# Vérification (toutes plateformes)
terraform version
# Terraform v1.9.8 — version recommandée pour ce tutoriel
Installation AWS CLI v2
# Linux x86_64
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o awscli.zip
unzip awscli.zip
sudo ./aws/install
# macOS
brew install awscli
# Windows
choco install awscli
# Vérification
aws --version
# aws-cli/2.17.0 Python/3.12.0 Linux/6.5.0
Configurer les credentials AWS
Créez un utilisateur IAM dédié à Terraform via la console AWS (IAM > Users > Create user). Attachez la policy AdministratorAccess pour ce tutoriel — en production, vous restreindrez ces permissions (voir section sécurité).
# Configurer un profil nommé "terraform-dev"
aws configure --profile terraform-dev
# AWS Access Key ID [None]: AKIA...
# AWS Secret Access Key [None]: wJalr...
# Default region name [None]: eu-west-3
# Default output format [None]: json
# Vérifier l'identité — affiche le compte AWS courant
aws sts get-caller-identity --profile terraform-dev
# {
# "UserId": "AIDA...",
# "Account": "123456789012",
# "Arn": "arn:aws:iam::123456789012:user/terraform-dev"
# }
~/.aws/credentials. Ne commitez jamais ce fichier sur Git. Pour la prod, utilisez plutôt un rôle IAM EC2 (instance profile) ou AWS SSO avec sessions temporaires.
Structure d'un projet Terraform
terraform-aws-starter/
├── main.tf # Resources principales
├── variables.tf # Variables d'entrée
├── outputs.tf # Valeurs de sortie
├── providers.tf # Configuration providers
├── terraform.tfvars # Valeurs des variables (non commité)
├── .terraform/ # Cache providers (auto-généré, gitignore)
└── .gitignore
# .gitignore minimal pour Terraform
.terraform/
*.tfstate
*.tfstate.backup
*.tfvars
.terraform.lock.hcl
crash.log
override.tf
Premier resource : un bucket S3
Avant d'attaquer le VPC + EC2 + RDS, créons un simple bucket S3 pour comprendre le cycle init → plan → apply → destroy. Un bucket est gratuit (5 Go free tier) et idéal pour apprendre.
providers.tf — déclarer le provider AWS
# providers.tf
terraform {
# Versions requises — pinning pour reproductibilité
required_version = ">= 1.9.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.70" # Compatible 5.70.x à 5.99.x
}
}
}
provider "aws" {
region = var.aws_region
profile = "terraform-dev" # Profil défini dans ~/.aws/credentials
# Tags par défaut appliqués à toutes les resources
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
}
}
}
variables.tf — paramétrer le projet
# variables.tf
variable "aws_region" {
description = "Région AWS où déployer les resources"
type = string
default = "eu-west-3" # Paris
}
variable "project_name" {
description = "Nom du projet (préfixe des resources)"
type = string
default = "afa-starter"
validation {
condition = can(regex("^[a-z0-9-]+$", var.project_name))
error_message = "Doit contenir uniquement [a-z0-9-]."
}
}
variable "environment" {
description = "Environnement (dev, staging, prod)"
type = string
default = "dev"
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Doit être dev, staging ou prod."
}
}
main.tf — créer le bucket S3
# main.tf
# random_id génère un suffixe unique pour le nom du bucket (globalement unique S3)
resource "random_id" "bucket_suffix" {
byte_length = 4
}
resource "aws_s3_bucket" "logs" {
bucket = "${var.project_name}-logs-${random_id.bucket_suffix.hex}"
tags = {
Name = "${var.project_name}-logs"
Purpose = "Application logs storage"
}
}
# Bloquer tout accès public (sécurité par défaut)
resource "aws_s3_bucket_public_access_block" "logs" {
bucket = aws_s3_bucket.logs.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Activer le chiffrement at-rest (AES256)
resource "aws_s3_bucket_server_side_encryption_configuration" "logs" {
bucket = aws_s3_bucket.logs.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
# Versioning pour récupérer les fichiers supprimés
resource "aws_s3_bucket_versioning" "logs" {
bucket = aws_s3_bucket.logs.id
versioning_configuration {
status = "Enabled"
}
}
# Le provider random doit être déclaré
# providers.tf — ajouter à required_providers
random = {
source = "hashicorp/random"
version = "~> 3.6"
}
outputs.tf — exposer les valeurs utiles
# outputs.tf
output "logs_bucket_name" {
description = "Nom du bucket S3 pour les logs"
value = aws_s3_bucket.logs.id
}
output "logs_bucket_arn" {
description = "ARN du bucket (utile pour les policies IAM)"
value = aws_s3_bucket.logs.arn
}
output "logs_bucket_region" {
description = "Région du bucket"
value = aws_s3_bucket.logs.region
}
Cycle de vie : init / plan / apply / destroy
# 1. init : télécharge les providers (à faire une fois par projet)
terraform init
# Initializing the backend...
# Initializing provider plugins...
# - Installing hashicorp/aws v5.70.0
# - Installing hashicorp/random v3.6.3
# Terraform has been successfully initialized!
# 2. plan : preview des changements (rien n'est modifié)
terraform plan
# Plan: 5 to add, 0 to change, 0 to destroy.
# 3. apply : crée réellement les resources sur AWS
terraform apply
# Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
# Outputs:
# logs_bucket_name = "afa-starter-logs-a1b2c3d4"
# 4. destroy : supprime tout (ATTENTION, irréversible)
terraform destroy
# Destroy complete! Resources: 5 destroyed.
terraform destroy chaque soir. Vous évitez les frais de nuit/weekend. Un script CI peut planifier l'apply le matin et le destroy le soir.
Stack complète : VPC + EC2 + RDS
Passons à un cas réel : un VPC isolé avec deux subnets (public et privé), une instance EC2 publique pour héberger une API Node.js, et une base RDS PostgreSQL dans le subnet privé.
Architecture cible
Internet
│
┌─────────┴──────────┐
│ Internet Gateway │
└─────────┬──────────┘
│
┌───────────────┼────────────────┐
│ VPC 10.0.0.0/16 │
│ │
│ ┌──────────────────────────┐ │
│ │ Public Subnet 10.0.1.0/24│ │
│ │ EC2 (API Node.js) │ │
│ │ Security Group : │ │
│ │ 80, 443, 22 │ │
│ └────────────┬─────────────┘ │
│ │ │
│ ┌────────────┴─────────────┐ │
│ │Private Subnet 10.0.2.0/24│ │
│ │ RDS PostgreSQL 16 │ │
│ │ Security Group : 5432 │ │
│ └──────────────────────────┘ │
└────────────────────────────────┘
main.tf — VPC + subnets + Internet Gateway
# VPC principal
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.project_name}-vpc" }
}
# Subnet public (EC2 accessible depuis Internet)
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = { Name = "${var.project_name}-public-subnet" }
}
# Subnet privé (RDS, non accessible depuis Internet)
resource "aws_subnet" "private_a" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = "${var.aws_region}a"
tags = { Name = "${var.project_name}-private-subnet-a" }
}
# Second subnet privé (RDS multi-AZ requiert 2 subnets dans 2 AZ différentes)
resource "aws_subnet" "private_b" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = "${var.aws_region}b"
tags = { Name = "${var.project_name}-private-subnet-b" }
}
# Internet Gateway pour le subnet public
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "${var.project_name}-igw" }
}
# Route table publique : tout le trafic 0.0.0.0/0 vers l'IGW
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 = { Name = "${var.project_name}-public-rt" }
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
Security Groups (firewall stateful)
# SG pour l'EC2 web — autorise HTTP, HTTPS, SSH
resource "aws_security_group" "web" {
name = "${var.project_name}-web-sg"
description = "Allow HTTP/HTTPS/SSH inbound"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "HTTPS from anywhere"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
ingress {
description = "SSH from admin IP only"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = [var.admin_ip_cidr] # ex: "82.65.123.45/32"
}
egress {
description = "All outbound"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.project_name}-web-sg" }
}
# SG pour la base RDS — autorise SEULEMENT le SG web sur le port 5432
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Allow PostgreSQL from web SG only"
vpc_id = aws_vpc.main.id
ingress {
description = "PostgreSQL from web SG"
from_port = 5432
to_port = 5432
protocol = "tcp"
security_groups = [aws_security_group.web.id] # référence par SG, pas par IP
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "${var.project_name}-db-sg" }
}
EC2 avec user_data pour bootstrap
# Récupère la dernière AMI Amazon Linux 2023 (data source)
data "aws_ami" "amazon_linux_2023" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.amazon_linux_2023.id
instance_type = var.ec2_instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
associate_public_ip_address = true
key_name = var.ec2_key_pair_name
# Script d'init exécuté au premier boot
user_data = <<-EOF
#!/bin/bash
dnf update -y
dnf install -y nodejs git
cd /home/ec2-user
git clone https://github.com/example/api-starter.git
cd api-starter && npm ci && npm run build
npm install -g pm2
pm2 start dist/server.js --name api
pm2 startup && pm2 save
EOF
root_block_device {
volume_size = 20
volume_type = "gp3"
encrypted = true
}
tags = { Name = "${var.project_name}-web" }
}
RDS PostgreSQL multi-AZ-ready
# DB Subnet Group : RDS doit être dans 2+ subnets de 2 AZ différentes
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_a.id, aws_subnet.private_b.id]
tags = { Name = "${var.project_name}-db-subnet-group" }
}
resource "aws_db_instance" "postgres" {
identifier = "${var.project_name}-postgres"
engine = "postgres"
engine_version = "16.4"
instance_class = "db.t3.micro" # Free tier-compatible
allocated_storage = 20
max_allocated_storage = 100 # Auto-scaling jusqu'à 100 Go
storage_encrypted = true
storage_type = "gp3"
db_name = "appdb"
username = "appadmin"
password = var.db_password # via terraform.tfvars (sensible)
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
publicly_accessible = false # Jamais en public
backup_retention_period = 7
backup_window = "03:00-04:00"
maintenance_window = "sun:04:00-sun:05:00"
skip_final_snapshot = var.environment != "prod" # Snapshot final en prod
deletion_protection = var.environment == "prod" # Anti-suppression accidentelle
tags = { Name = "${var.project_name}-postgres" }
}
terraform.tfvars. Section dédiée plus bas.
Modules : factoriser l'infrastructure
Quand vous dupliquez les blocs VPC + EC2 + RDS pour dev, staging et prod, le code devient ingérable. Les modules Terraform encapsulent une infra réutilisable, comme une fonction qui prend des inputs et retourne des outputs.
Structure d'un projet avec modules
terraform-aws-app/
├── modules/
│ ├── vpc/
│ │ ├── main.tf # Resources VPC, subnets, IGW
│ │ ├── variables.tf # Inputs : cidr, az_count, name_prefix
│ │ └── outputs.tf # Outputs : vpc_id, public_subnet_ids, private_subnet_ids
│ ├── compute/
│ │ ├── main.tf
│ │ ├── variables.tf
│ │ └── outputs.tf
│ └── database/
│ ├── main.tf
│ ├── variables.tf
│ └── outputs.tf
├── environments/
│ ├── dev/
│ │ ├── main.tf # Appelle les modules avec params dev
│ │ └── terraform.tfvars
│ ├── staging/
│ │ └── main.tf
│ └── prod/
│ └── main.tf
└── README.md
Définition du module VPC
# modules/vpc/variables.tf
variable "name_prefix" {
type = string
}
variable "vpc_cidr" {
type = string
default = "10.0.0.0/16"
}
variable "az_count" {
type = number
default = 2
}
# modules/vpc/main.tf
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = { Name = "${var.name_prefix}-vpc" }
}
# Crée N subnets publics (un par AZ)
resource "aws_subnet" "public" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index)
availability_zone = data.aws_availability_zones.available.names[count.index]
map_public_ip_on_launch = true
tags = { Name = "${var.name_prefix}-public-${count.index}" }
}
# Crée N subnets privés
resource "aws_subnet" "private" {
count = var.az_count
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 8, count.index + 100)
availability_zone = data.aws_availability_zones.available.names[count.index]
tags = { Name = "${var.name_prefix}-private-${count.index}" }
}
# modules/vpc/outputs.tf
output "vpc_id" {
value = aws_vpc.this.id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
Utilisation du module en dev/staging/prod
# environments/dev/main.tf
module "network" {
source = "../../modules/vpc"
name_prefix = "afa-dev"
vpc_cidr = "10.0.0.0/16"
az_count = 2
}
module "database" {
source = "../../modules/database"
name_prefix = "afa-dev"
subnet_ids = module.network.private_subnet_ids
vpc_id = module.network.vpc_id
instance_class = "db.t3.micro" # Petit en dev
multi_az = false
}
# environments/prod/main.tf — même module, params différents
module "network" {
source = "../../modules/vpc"
name_prefix = "afa-prod"
vpc_cidr = "10.10.0.0/16"
az_count = 3 # Trois AZ pour haute dispo
}
module "database" {
source = "../../modules/database"
name_prefix = "afa-prod"
subnet_ids = module.network.private_subnet_ids
vpc_id = module.network.vpc_id
instance_class = "db.r6g.large" # Plus puissant en prod
multi_az = true # Haute disponibilité
}
Modules de la communauté
Le Terraform Registry (registry.terraform.io) héberge des milliers de modules vérifiés. Le module officiel terraform-aws-modules/vpc/aws gère VPC, subnets, NAT, route tables et flow logs en 50 lignes :
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.13"
name = "afa-prod-vpc"
cidr = "10.10.0.0/16"
azs = ["eu-west-3a", "eu-west-3b", "eu-west-3c"]
private_subnets = ["10.10.1.0/24", "10.10.2.0/24", "10.10.3.0/24"]
public_subnets = ["10.10.101.0/24", "10.10.102.0/24", "10.10.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = false # Un NAT par AZ pour la résilience
enable_dns_hostnames = true
enable_flow_log = true
tags = { Environment = "prod", ManagedBy = "Terraform" }
}
version = "~> 5.13"). Sans ça, une nouvelle version majeure peut casser votre infra à votre prochain terraform init -upgrade.
State remote S3 + DynamoDB lock
Le state file Terraform (terraform.tfstate) est un JSON qui mappe chaque resource HCL à son ID AWS réel. En local, il bloque le travail en équipe : si Alice et Bob lancent terraform apply simultanément, ils corrompent le state.
Backend S3 + verrouillage DynamoDB
La solution standard : stocker le state dans un bucket S3 (versionné, chiffré) et utiliser une table DynamoDB pour le verrouillage (un seul apply à la fois).
Bootstrap : créer le bucket et la table
# bootstrap/main.tf — à appliquer UNE FOIS avec state local
# Puis migrer vers ce backend pour les autres projets
resource "aws_s3_bucket" "tfstate" {
bucket = "afa-tfstate-${data.aws_caller_identity.current.account_id}"
}
resource "aws_s3_bucket_versioning" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
versioning_configuration { status = "Enabled" }
}
resource "aws_s3_bucket_server_side_encryption_configuration" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}
resource "aws_s3_bucket_public_access_block" "tfstate" {
bucket = aws_s3_bucket.tfstate.id
block_public_acls = true
block_public_policy = true
ignore_public_acls = true
restrict_public_buckets = true
}
# Table DynamoDB pour le verrouillage
resource "aws_dynamodb_table" "tfstate_lock" {
name = "terraform-state-lock"
billing_mode = "PAY_PER_REQUEST" # Pas de capacité provisionnée
hash_key = "LockID"
attribute {
name = "LockID"
type = "S"
}
}
data "aws_caller_identity" "current" {}
Configurer le backend S3 dans un projet
# providers.tf — ajouter le bloc backend
terraform {
required_version = ">= 1.9.0"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.70" }
}
backend "s3" {
bucket = "afa-tfstate-123456789012"
key = "envs/dev/terraform.tfstate"
region = "eu-west-3"
profile = "terraform-dev"
dynamodb_table = "terraform-state-lock"
encrypt = true
}
}
Migration du state local vers S3
# Si vous avez déjà un state local, Terraform propose de le migrer
terraform init -migrate-state
# Initializing the backend...
# Do you want to copy existing state to the new backend?
# Pre-existing state was found while migrating the previous "local"
# backend to the newly configured "s3" backend.
#
# Enter a value: yes
#
# Successfully configured the backend "s3"!
Workspaces dev / staging / prod
Les workspaces permettent d'isoler le state par environnement avec le même code Terraform. Chaque workspace écrit dans une clé S3 différente.
# Lister les workspaces (default existe par défaut)
terraform workspace list
# Créer dev / staging / prod
terraform workspace new dev
terraform workspace new staging
terraform workspace new prod
# Basculer
terraform workspace select dev
terraform plan
terraform apply # Crée les resources dans le state "dev"
# Référencer le workspace courant dans le code
locals {
environment = terraform.workspace # "dev", "staging" ou "prod"
}
environments/dev/, environments/prod/ avec des backends séparés. C'est l'approche officiellement recommandée par HashiCorp pour la production.
Drift detection en CI
# .github/workflows/terraform-drift.yml
name: Terraform Drift Detection
on:
schedule:
- cron: "0 6 * * *" # Tous les jours à 6h UTC
workflow_dispatch:
jobs:
drift:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: hashicorp/setup-terraform@v3
with:
terraform_version: 1.9.8
- run: terraform init
env:
AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }}
AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
- name: Detect drift
run: |
terraform plan -detailed-exitcode -out=tfplan
continue-on-error: true
id: plan
- name: Notify Slack on drift
if: steps.plan.outputs.exitcode == '2'
run: |
curl -X POST -H 'Content-type: application/json' \
--data '{"text":"Terraform drift detected on prod !"}' \
${{ secrets.SLACK_WEBHOOK_URL }}
Bonnes pratiques et sécurité
Secrets : AWS Secrets Manager, jamais en clair
# Récupérer un secret existant via data source
data "aws_secretsmanager_secret" "db_password" {
name = "afa/prod/db_password"
}
data "aws_secretsmanager_secret_version" "db_password" {
secret_id = data.aws_secretsmanager_secret.db_password.id
}
resource "aws_db_instance" "postgres" {
# ...
password = data.aws_secretsmanager_secret_version.db_password.secret_string
}
IAM least-privilege pour Terraform
En production, n'attachez jamais AdministratorAccess à votre user Terraform. Construisez une policy ciblée listant uniquement les actions nécessaires.
{
"Version": "2012-10-17",
"Statement": [
{
"Sid": "VPCAndCompute",
"Effect": "Allow",
"Action": [
"ec2:*",
"rds:*",
"iam:GetRole",
"iam:PassRole"
],
"Resource": "*"
},
{
"Sid": "S3StateAccess",
"Effect": "Allow",
"Action": [
"s3:GetObject",
"s3:PutObject",
"s3:DeleteObject"
],
"Resource": "arn:aws:s3:::afa-tfstate-*/envs/*"
},
{
"Sid": "DynamoDBLock",
"Effect": "Allow",
"Action": [
"dynamodb:GetItem",
"dynamodb:PutItem",
"dynamodb:DeleteItem"
],
"Resource": "arn:aws:dynamodb:*:*:table/terraform-state-lock"
}
]
}
tflint : linter pour HCL
# Installation
brew install tflint
# ou
curl -s https://raw.githubusercontent.com/terraform-linters/tflint/master/install_linux.sh | bash
# Plugin AWS
cat > .tflint.hcl <<EOF
plugin "aws" {
enabled = true
version = "0.32.0"
source = "github.com/terraform-linters/tflint-ruleset-aws"
}
EOF
tflint --init
tflint
# Affiche : t3.micro pas dispo en eu-west-3 ? warning AMI obsolète, etc.
Atlantis : review Terraform via Pull Request
Atlantis est un service open-source qui automatise les terraform plan dans vos Pull Requests GitHub/GitLab. Plus aucun apply manuel — tout passe par PR review.
# atlantis.yaml — fichier de config à la racine du repo
version: 3
projects:
- name: dev
dir: environments/dev
workspace: dev
autoplan:
enabled: true
when_modified: ["*.tf", "../../modules/**/*.tf"]
apply_requirements: [approved, mergeable]
- name: prod
dir: environments/prod
workspace: prod
autoplan:
enabled: false # Plan manuel uniquement en prod
apply_requirements: [approved, mergeable]
Workflow type avec Atlantis :
- Vous ouvrez une PR modifiant
environments/dev/main.tf. - Atlantis poste le
terraform planen commentaire de la PR. - Le reviewer approuve → vous commentez
atlantis apply. - Atlantis exécute l'apply et merge automatiquement la PR.
Tags AWS systématiques
provider "aws" {
region = var.aws_region
default_tags {
tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "Terraform"
Owner = "platform-team@example.com"
CostCenter = "engineering"
Repository = "github.com/example/terraform-aws-app"
}
}
}
Les tags permettent de filtrer le coût AWS par projet/environnement (Cost Explorer), d'identifier le propriétaire d'une resource orpheline et d'écrire des SCP IAM ciblées.
- State remote S3 + DynamoDB lock activés
- Versioning S3 activé sur le bucket de state (rollback possible)
- Encryption at-rest activée (S3, EBS, RDS)
- Tous les secrets dans AWS Secrets Manager (jamais dans tfvars commités)
- IAM user/role Terraform avec policy restreinte (pas
*:*) - Drift detection en CI quotidien (cron + alerting Slack)
- tflint dans la pipeline CI avant chaque merge
- Tags AWS systématiques (project, environment, owner)
deletion_protection = truesur les resources critiques (RDS, ALB)- Pinning strict des versions Terraform et providers
- Review obligatoire via Atlantis ou Terraform Cloud — pas d'apply local en prod
- Backup automatiques RDS ≥ 7 jours, snapshots avant migrations
Conclusion
Vous tenez maintenant les fondamentaux de Terraform appliqués à AWS : la syntaxe HCL, le cycle init / plan / apply / destroy, la composition VPC + subnets + EC2 + RDS, l'extraction en modules réutilisables, le state remote S3 avec verrouillage DynamoDB, et les outils de qualité comme tflint et Atlantis. C'est exactement le socle utilisé par la plupart des équipes DevOps en production.
Les prochaines étapes pour aller plus loin : intégrer Terraform à votre pipeline CI/CD (GitHub Actions, GitLab CI), explorer Terragrunt pour gérer plusieurs environnements et modules sans duplication, et OpenTofu (le fork open-source de Terraform après le changement de licence HashiCorp en 2023). Pensez aussi à activer AWS Config pour auditer la conformité de votre infra et à tester votre code IaC avec Terratest en Go pour les modules critiques.
Surtout, traitez votre code Terraform avec la même rigueur que votre code applicatif : revue de PR systématique, CI obligatoire, tests sur les modules réutilisables, et documentation à jour. L'infrastructure devient alors un produit logiciel comme un autre — versionnée, testée, déployable et reproductible.