Cloud & Déploiement angularforall.com

- Terraform : Infrastructure AWS as Code de zéro

Terraform Aws Iac Devops Infrastructure-As-Code Vpc Ec2 Rds S3-Backend Dynamodb-Lock Hcl Modules-Terraform Atlantis Tflint
Terraform : Infrastructure AWS as Code de zéro

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
Choix recommandé : Terraform si vous voulez du multi-cloud ou un large catalogue de modules communautaires. CloudFormation si vous êtes 100 % AWS et préférez une intégration native avec Service Catalog et StackSets. Pulumi si votre équipe préfère TypeScript/Python à HCL.

Les bénéfices concrets en équipe

  • Reproductibilité : un même fichier main.tf cré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 plan détecte les modifications manuelles non autorisées dans la console.
  • Disaster recovery : une région AWS indisponible ? terraform apply dans 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"
# }
Les credentials sont stockés dans ~/.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.
Astuce coût : Pour un environnement de test, lancez 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" }
}
Sécurité : Le mot de passe RDS doit venir d'AWS Secrets Manager en production, pas d'un fichier 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" }
}
Toujours pinner la version d'un module externe (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"
}
Workspaces vs dossiers d'environnement : Pour 2-3 envs très similaires, les workspaces suffisent. Pour des envs très différents (régions, comptes AWS distincts), préférez la structure 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 plan en 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.

Checklist mise en production :
  • 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 = true sur 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
Coût estimé production minimale : EC2 t3.small (~15€/mois), RDS db.t3.small Multi-AZ (~50€/mois), NAT Gateway 2 AZ (~60€/mois), ALB (~22€/mois), data transfer (~20€/mois). Total ~170€/mois pour une stack web 2 AZ haute-dispo. Le NAT Gateway est souvent le poste le plus coûteux — alternative : NAT Instance EC2 spot pour dev.

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.

Partager