Provisionner un VPC AWS sécurisé avec Terraform
Ce tutoriel construit en Terraform un VPC AWS prêt pour la production, avec des sous-réseaux publics et privés sur deux zones de disponibilité, des passerelles NAT, des tables de routage et des security groups qui n'ouvrent que le trafic entrant nécessaire. Il couvre aussi les valeurs par défaut d'AWS qui provoquent des incidents quand personne n'y touche.
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.
Sous-réseaux publics et privés répartis sur deux zones de disponibilité
Une architecture sur une seule AZ fonctionne très bien jusqu'au jour où cette AZ a un problème, et ce jour-là tout l'environnement tombe. Chaque environnement que je provisionne s'étend dès le premier jour sur au moins deux AZ, avec des sous-réseaux publics pour ce qui a besoin d'une route directe vers internet (répartiteurs de charge, passerelles NAT) et des sous-réseaux privés pour le reste (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.
Passerelle Internet et passerelle NAT pour la sortie réseau des ressources privées
Les sous-réseaux publics rejoignent internet via une passerelle Internet (Internet Gateway). Les sous-réseaux 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 passerelle NAT (NAT Gateway). Elle se place dans un sous-réseau public et permet aux ressources des sous-réseaux privés d'ouvrir 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 passerelle NAT est facturée à l'heure de provisionnement, plus au Go de données traitées. En déployer une par AZ pour la haute disponibilité double la facture par rapport à la passerelle NAT unique et partagée que montrent la plupart des tutoriels. C'est le bon choix en production, mais faites-le en connaissance de cause.
Tables de routage
Les sous-réseaux publics routent 0.0.0.0/0 vers la passerelle Internet ; les sous-réseaux privés le routent vers la passerelle NAT de leur propre AZ, de sorte qu'une panne de passerelle 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, sous-réseaux et tables de routage assemblés :
Security groups : un trafic entrant en moindre privilège
Les security groups créés dans la console ont presque toujours au moins une règle entrante 0.0.0.0/0, ouverte pendant une séance de débogage et jamais refermée. Je définis ici deux groupes. Le premier sert au load balancer public, le second à la couche applicative, qui n'accepte que le HTTPS venant de ce load balancer.
resource "aws_security_group" "alb" {
name = "acme-prod-alb-sg"
description = "ALB public - HTTPS entrant depuis internet"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTPS depuis internet"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "HTTPS vers la couche applicative dans les sous-reseaux prives"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = [for s in aws_subnet.private : s.cidr_block]
}
tags = { Name = "acme-prod-alb-sg" }
}
resource "aws_security_group" "app" {
name = "acme-prod-app-sg"
description = "Couche applicative - HTTPS entrant depuis 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 {
description = "Tout le sortant - volontairement ouvert, voir plus bas"
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "acme-prod-app-sg" }
}En prenant aws_security_group.alb.id comme source d'ingress plutôt qu'un bloc CIDR, seul le trafic issu du security group du load balancer peut entrer, quelle que soit la plage d'adresses IP de l'ALB. La règle se documente aussi d'elle-même. Dans six mois, elle vous dira quel composant a le droit de se connecter, sans que vous ayez à deviner à quoi correspondait une plage d'adresses. Les descriptions sont volontairement sans accents ni apostrophes, car AWS n'accepte qu'un jeu restreint de caractères ASCII dans les descriptions de security groups et de règles. L'egress de l'ALB utilise les CIDR des sous-réseaux privés plutôt que aws_security_group.app.id, parce que deux groupes qui se référencent mutuellement en ligne créent un cycle de dépendances. Si vous voulez des références entre security groups des deux côtés, déplacez les règles dans des ressources aws_vpc_security_group_ingress_rule / aws_vpc_security_group_egress_rule séparées.
Le trafic sortant de la couche applicative est une autre affaire. Je l'ai laissé ouvert sur 0.0.0.0/0 pour garder l'exemple court, et parce que les miroirs de paquets, les registres de conteneurs et les API tierces ont rarement des plages d'IP stables. Ce n'est pas du moindre privilège. Un egress ouvert est justement le chemin qu'emprunte une charge de travail compromise pour exfiltrer des données ou joindre son serveur de commande. Une fois l'environnement stabilisé, je le resserrerais avec des VPC endpoints pour les services AWS que vous utilisez (S3, ECR, STS), des CIDR ou des prefix lists explicites pour les destinations connues, et un proxy de sortie ou AWS Network Firewall avec une liste de domaines autorisés pour le reste.
Chaque VPC est livré avec un security group par défaut qui autorise tout le trafic entre ses membres et tout le trafic sortant. Toute ressource lancée sans groupe explicite s'y retrouve. Je le reprends dans Terraform et je supprime toutes ses règles, pour que personne ne s'appuie dessus par accident.
# Aucun bloc ingress ni egress : Terraform supprime toutes les règles du SG par défaut
resource "aws_default_security_group" "default" {
vpc_id = aws_vpc.main.id
tags = { Name = "acme-prod-default-sg-do-not-use" }
}Cela fait treize blocs de ressources et quelques boucles for_each, et le réseau se comporte déjà comme en production. La perte d'une AZ dégrade le service sans faire tomber tout l'environnement. Chaque règle renvoie à un commit Git, et le prochain ingénieur peut lire la topologie dans le code au lieu de devoir la reconstituer.
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
Lambda & EventBridge : architecture événementielle
Concevez un pipeline événementiel sur Lambda et EventBridge qui gère messages empoisonnés, retries et cold starts.
Site statique sécurisé sur AWS S3 & CloudFront
Hébergez un site statique sur S3 derrière CloudFront avec Origin Access Control, TLS ACM et invalidation de cache.
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.