Provisionner un VPC AWS sécurisé avec Terraform
Ce tutoriel construit un VPC AWS de forme production en Terraform, avec des sous-réseaux publics/privés répartis sur deux zones de disponibilité, des NAT gateways, des tables de routage et des security groups en moindre privilège, en couvrant la poignée de valeurs par défaut qui provoquent silencieusement des incidents si on n'y touche pas.
Planification du VPC et du CIDR
Avant d'écrire la moindre ligne de Terraform, je décide de l'espace d'adressage, en anticipant que le compte accueillera probablement plusieurs VPC à terme. Un /16 donne 65 536 adresses, largement surdimensionné pour la plupart des charges de travail, mais cela évite de jamais avoir à retoucher le CIDR du VPC (le modifier plus tard implique de recréer le VPC).
resource "aws_vpc" "main" {
cidr_block = "10.20.0.0/16"
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "acme-prod-vpc"
Environment = "production"
ManagedBy = "terraform"
}
}enable_dns_hostnames est facile à oublier, et c'est souvent la raison pour laquelle un VPC tout neuf n'arrive pas à résoudre les noms internes des services, ou à joindre certains endpoints gérés par AWS. Je le fixe explicitement plutôt que de faire confiance à la valeur par défaut.
Subnets publics et privés répartis sur deux zones de disponibilité
Une disposition sur une seule AZ fonctionne très bien, jusqu'au jour où cette AZ a un problème : à ce moment-là, c'est une panne complète, pas un détail réseau. Chaque environnement que je provisionne s'étend dès le premier jour sur au moins deux AZ, réparties en subnets publics (pour tout ce qui a besoin d'une route directe vers internet : répartiteurs de charge, passerelles NAT) et subnets privés (serveurs applicatifs, bases de données, services internes).
resource "aws_subnet" "public" {
for_each = {
"us-east-1a" = "10.20.0.0/24"
"us-east-1b" = "10.20.1.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
map_public_ip_on_launch = true
tags = {
Name = "acme-prod-public-${each.key}"
Tier = "public"
}
}
resource "aws_subnet" "private" {
for_each = {
"us-east-1a" = "10.20.10.0/24"
"us-east-1b" = "10.20.11.0/24"
}
vpc_id = aws_vpc.main.id
cidr_block = each.value
availability_zone = each.key
tags = {
Name = "acme-prod-private-${each.key}"
Tier = "private"
}
}Déployer dans une seule AZ « pour simplifier » n'élimine pas le point unique de défaillance, cela le déplace simplement vers la couche réseau, où il est moins visible jusqu'au jour où l'AZ tombe réellement.
Internet Gateway et NAT Gateway pour la sortie réseau des ressources privées
Les subnets publics rejoignent internet via une Internet Gateway. Les subnets privés ont eux aussi besoin d'un accès sortant (installation de paquets, appels API tiers, récupération d'images de conteneurs) sans être directement joignables depuis internet. C'est le rôle de la NAT Gateway : elle vit dans un subnet public et permet aux ressources des subnets privés d'initier des connexions sortantes.
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = { Name = "acme-prod-igw" }
}
resource "aws_eip" "nat" {
for_each = aws_subnet.public
domain = "vpc"
tags = { Name = "acme-prod-nat-eip-${each.key}" }
}
resource "aws_nat_gateway" "main" {
for_each = aws_subnet.public
allocation_id = aws_eip.nat[each.key].id
subnet_id = each.value.id
tags = { Name = "acme-prod-nat-${each.key}" }
}Une NAT Gateway est facturée à l'heure de provisionnement, plus au Go de données traitées. En avoir une par AZ pour la haute disponibilité double silencieusement la facture, comparé à la NAT Gateway unique et partagée que montrent la plupart des tutoriels. C'est le bon choix en production, mais assurez-vous que c'est délibéré.
Tables de routage
Les subnets publics routent 0.0.0.0/0 vers l'Internet Gateway ; les subnets privés le routent vers la NAT Gateway de leur propre AZ, de sorte qu'une panne de NAT dans une zone n'interrompe pas la sortie réseau de l'autre.
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 = "acme-prod-public-rt" }
}
resource "aws_route_table_association" "public" {
for_each = aws_subnet.public
subnet_id = each.value.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
for_each = aws_subnet.private
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.main[each.key].id
}
tags = { Name = "acme-prod-private-rt-${each.key}" }
}
resource "aws_route_table_association" "private" {
for_each = aws_subnet.private
subnet_id = each.value.id
route_table_id = aws_route_table.private[each.key].id
}Voici la topologie complète une fois les passerelles, subnets et tables de routage assemblés :
Un security group en moindre privilège
Les security groups construits à la main comportent généralement au moins une règle 0.0.0.0/0 ouverte pendant une session de débogage et jamais revue depuis. Le groupe ci-dessous n'autorise que ce dont une couche applicative a réellement besoin : du HTTPS entrant depuis un load balancer, rien d'autre en entrée, et un trafic sortant non restreint (sûr par défaut, puisqu'il s'agit de trafic sortant, pas entrant).
resource "aws_security_group" "app" {
name = "acme-prod-app-sg"
description = "Couche applicative - HTTPS entrant depuis l'ALB uniquement"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS depuis le load balancer"
from_port = 443
to_port = 443
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "acme-prod-app-sg" }
}Référencer aws_security_group.alb.id comme source d'ingress plutôt qu'un bloc CIDR signifie que seul le trafic provenant réellement du security group de ce load balancer précis est autorisé à entrer, quelle que soit la plage d'adresses IP sur laquelle l'ALB se trouve. C'est aussi auto-documenté : dans six mois, le security group vous dit ce qui est autorisé à se connecter, plutôt que de vous laisser déchiffrer quels numéros.
Cinq blocs de ressources et deux ou trois boucles for_each, et le réseau se comporte déjà comme en production : une panne d'AZ dégrade le service au lieu d'emporter tout l'environnement, les règles remontent à un commit Git plutôt qu'au souvenir de quelqu'un, et le prochain ingénieur peut lire la topologie au lieu de la rétro-ingénierer.
Envie de faire tourner ça en production ?
Ce tutoriel couvre les concepts et l'architecture. Si vous voulez l'implémenter dans votre propre infrastructure, ou monter en compétence pour posséder ce sujet durablement, je propose du mentorat individuel construit autour de votre environnement réel, pas une formation générique.
Ce tutoriel
- Concepts clés et architecture principale
- Extraits de code illustratifs
- Le raisonnement derrière chaque décision
Mentorat individuel
- Des sessions de travail sur votre propre environnement
- Des réponses directes aux cas particuliers que vous rencontrez
- Un retour sur votre implémentation réelle
- Un accompagnement continu pendant que vous la construisez
Tutoriels similaires
Refactoriser un Terraform legacy multi-équipes
Un chemin concret d'un unique fichier d'état Terraform tentaculaire vers des modules versionnés et des garde-fous policy-as-code partagés.
Le guide 2026 d'optimisation des coûts Cloud
Un cadre éprouvé pour réduire les dépenses cloud sans sacrifier la fiabilité : dimensionnement, remises d'engagement, et ce qui fait durer les économies.