Aller au contenu
English
English
Infrastructure as Code medium

Terraform AWS, Rôle IAM et instance profile

60 min de lecture

logo terraform

Une EC2 ne devrait jamais "tout pouvoir faire" par défaut. Dès qu'une machine doit lire un bucket S3, interroger une API AWS ou décrire d'autres ressources, vous devez choisir exactement quelles permissions lui donner. Sinon, la moindre fuite de credentials ou la moindre erreur de configuration ouvre beaucoup trop de droits. Ce guide introduit donc le trio essentiel d'AWS côté identité : policy, rôle et instance profile.

L'enchaînement paraît abstrait tant qu'on ne sait pas ce que fait chaque objet. La policy est un document JSON qui énumère des actions autorisées ou refusées. Le rôle est une identité IAM sans mot de passe : il porte les policies attachées et une trust policy qui désigne qui a le droit de l'endosser. L'instance profile existe parce qu'une EC2 n'accepte pas un rôle directement : l'API RunInstances ne prend qu'un nom d'instance profile, lequel référence exactement un rôle. Le service de métadonnées de l'instance distribue ensuite des credentials temporaires que le SDK et l'AWS CLI récupèrent tout seuls.

  • Créer une policy IAM avec aws_iam_policy_document (JSON as code)
  • Créer un rôle IAM avec une trust policy pour EC2
  • Attacher une policy à un rôle avec aws_iam_role_policy_attachment
  • Créer un instance profile pour lier le rôle à une EC2
  • Ajouter le profil à une instance via iam_instance_profile
  • Vérifier les ARNs dans les outputs

Après le provider et le réseau, la question suivante est logique : que peut faire l'instance une fois démarrée ? Dans AWS, la réponse passe par IAM. Contrairement à un script local, une EC2 ne devrait jamais embarquer de clés statiques dans ses fichiers.

Ce guide montre donc la bonne direction dès le début : donner à l'instance un rôle ciblé, avec des permissions explicites et réutilisables.

Vous allez construire les quatre objets dans l'ordre où ils dépendent les uns des autres, car aucun ne se crée isolément : la policy avant le rôle, le rôle avant l'instance profile, et l'instance profile avant l'EC2. Terraform déduit cet ordre tout seul à partir des références entre ressources, ce qui explique qu'aucun depends_on n'apparaisse dans le code de ce guide. Le résultat final tient en quatre éléments :

  1. Une policy IAM qui permet de lire depuis S3 et décrire des instances
  2. Un rôle IAM qui assume cette policy
  3. Un instance profile qui lie le rôle
  4. Une EC2 qui hérite des permissions via le profil

Durée estimée : 15 minutes Coût : peut être couvert par l'offre gratuite, selon la date de création de votre compte. Détail et vérification juste en dessous.

L'offre gratuite AWS a changé le 15 juillet 2025, et le type d'instance à choisir en dépend. Un compte ouvert avant cette date garde t2.micro et t3.micro ; un compte ouvert depuis n'a plus t2.micro du tout, mais t3.micro, t3.small, t4g.micro, t4g.small, c7i-flex.large et m7i-flex.large.

Compte avant le 15/07/2025Compte depuis le 15/07/2025
Types éligiblest2.micro, t3.microt3.micro, t3.small, t4g.micro, t4g.small, c7i-flex.large, m7i-flex.large
Durée12 mois6 mois, ou jusqu'à épuisement des crédits
Dépassementfacturé au tarif normaldépend du plan choisi, voir ci-dessous

La colonne de droite demande une précision qu'AWS place ailleurs : depuis juillet 2025, tout nouveau compte choisit à l'inscription entre un free plan et un paid plan. Les six mois et l'impossibilité de dépasser ne valent que pour le free plan ; en paid plan, l'usage au-delà des crédits est facturé au tarif normal. Le crédit d'accueil est de 100 USD, porté jusqu'à 200 USD en réalisant les activités proposées.

Ces labs retiennent donc t3.micro, le seul type éligible dans les deux cas. Plutôt que de faire confiance à une liste qui rebougera, demandez à votre compte ce à quoi il a droit :

Fenêtre de terminal
aws ec2 describe-instance-types \
--filters Name=free-tier-eligible,Values=true \
--query "InstanceTypes[*].[InstanceType]" \
--output text | sort

Une instance reste facturable dès que vous sortez de ces limites, et le stockage EBS compte séparément. Détruisez systématiquement le lab avec terraform destroy une fois l'exercice terminé.

Terraform raisonne par répertoire de travail : il charge tous les fichiers .tf du dossier courant et y crée son état. Travaillez donc dans un répertoire vide et dédié, sinon terraform apply prendra en compte des ressources d'un autre projet.

Fenêtre de terminal
mkdir -p ~/terraform-aws-iam-ec2
cd ~/terraform-aws-iam-ec2

Ce fichier verrouille les versions avant tout le reste. required_version refuse d'exécuter le code avec un binaire Terraform trop ancien, et la contrainte ~> 5.0 autorise les mises à jour mineures du provider AWS en retenant le passage à la majeure. Non pas que la 6.x casse cette configuration, le commentaire ci-dessous dit le contraire, mais parce qu'un changement de majeure ne se publie qu'une fois rejoué. La région n'est pas codée en dur : elle vient d'une variable définie à l'étape suivante.

Créez versions.tf :

terraform {
required_version = ">= 1.11.0"
required_providers {
aws = {
source = "hashicorp/aws"
# Série 5.x assumée. La 6.x est la série courante : la configuration
# de ce guide y est VALIDE, vérifié le 2026-09-22 par un
# `terraform init` puis `validate` en 6.66.0. La contrainte ne bouge
# pas tant que le cycle complet (plan, apply, destroy) n'a pas été
# rejoué sur un compte réel.
version = "~> 5.0"
}
}
}
provider "aws" {
region = var.aws_region
}

Chaque variable porte une valeur par défaut, donc le code s'applique tel quel sans fichier de valeurs. Deux d'entre elles méritent votre attention : role_name doit être unique dans le compte AWS, sinon la création échoue, et instance_type conditionne la facturation, t3.micro restant dans l'offre gratuite.

Créez variables.tf :

variable "aws_region" {
description = "AWS region"
type = string
default = "us-east-1"
}
variable "instance_name" {
description = "Name tag for the EC2 instance"
type = string
default = "lab03-instance-with-iam"
}
variable "instance_type" {
# t3.micro est eligible a l'offre gratuite quelle que soit la date de
# creation du compte, contrairement a t2.micro.
description = "Type d'instance EC2"
type = string
default = "t3.micro"
}
variable "role_name" {
description = "Name of the IAM role"
type = string
default = "lab03-ec2-role"
}

La data source aws_iam_policy_document ne crée rien dans AWS : elle assemble un document JSON que d'autres ressources consommeront. Chaque bloc statement devient une entrée du tableau Statement, et son attribut .json contient le résultat rendu. Un détail sur le second statement : l'action ec2:DescribeInstances ne gère pas les permissions au niveau ressource côté AWS, d'où le resources = ["*"] qui n'est pas ici une négligence.

Créez main.tf avec cette data source :

# Créer une policy IAM document (JSON policy as code)
data "aws_iam_policy_document" "s3_read_policy" {
statement {
effect = "Allow"
actions = [
"s3:GetObject",
"s3:ListBucket"
]
resources = [
"arn:aws:s3:::*",
"arn:aws:s3:::*/*"
]
}
statement {
effect = "Allow"
actions = ["ec2:DescribeInstances"]
resources = ["*"]
}
}

Ce bloc définit :

  • Statement 1 : Lire depuis S3 (ListBucket, GetObject) sur tous les buckets
  • Statement 2 : Décrire les instances EC2 (lire les métadonnées)

La data source génère automatiquement le JSON, pas besoin de l'écrire manuellement.

Le rôle se crée d'abord vide de permissions : l'unique policy qu'il porte à ce stade est sa trust policy, celle qui répond à « qui peut endosser ce rôle ». Ici, le principal est le service ec2.amazonaws.com, ce qui autorise n'importe quelle instance de votre compte à demander les credentials du rôle, à condition qu'elle en reçoive l'instance profile. Sans ce bloc, l'appel sts:AssumeRole échouerait.

Toujours dans main.tf, ajoutez :

resource "aws_iam_role" "lab03_role" {
name = var.role_name
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [
{
Effect = "Allow"
Principal = {
Service = "ec2.amazonaws.com"
}
Action = "sts:AssumeRole"
}
]
})
tags = {
Name = var.role_name
}
}

Ce bloc crée :

  • Un rôle IAM = conteneur de permissions
  • Une trust policy = qui peut utiliser ce rôle ?
    • Principal: { Service: "ec2.amazonaws.com" } = Les instances EC2 peuvent assumer ce rôle
    • Action: "sts:AssumeRole" = Permission pour les EC2 de prendre le rôle

Cette étape transforme le document généré à l'étape 3 en objet réel dans AWS, avec son propre ARN. C'est cet ARN qui rend la policy réutilisable : d'autres rôles pourront s'y attacher sans dupliquer le JSON. Le name doit être unique dans le compte, comme celui du rôle.

Toujours dans main.tf, ajoutez :

resource "aws_iam_policy" "s3_read_policy" {
name = "lab03-s3-read-policy"
description = "Allow EC2 to read from S3 and describe instances"
policy = data.aws_iam_policy_document.s3_read_policy.json
tags = {
Name = "lab03-s3-read-policy"
}
}

Ce bloc :

  • Crée une policy managée AWS à partir du document défini plus haut
  • policy = data.aws_iam_policy_document.s3_read_policy.json : utilise le JSON généré

L'attachement est une ressource à part entière, pas un argument du rôle. Cette séparation permet de détacher une policy sans détruire le rôle, et d'attacher la même policy à plusieurs rôles. Notez que la ressource référence le rôle par son name et la policy par son arn : ce n'est pas une incohérence, c'est ce qu'attend l'API IAM.

Toujours dans main.tf, ajoutez :

resource "aws_iam_role_policy_attachment" "attach_s3_policy" {
role = aws_iam_role.lab03_role.name
policy_arn = aws_iam_policy.s3_read_policy.arn
}

Ce bloc dit :

  • « Attache la policy s3_read_policy au rôle lab03_role »
  • L'EC2 pourra maintenant faire ce que la policy autorise

C'est l'étape que l'on oublie, parce qu'elle n'a pas d'équivalent visible dans la console AWS : celle-ci crée l'instance profile en arrière-plan quand vous associez un rôle à une instance. En Terraform, rien n'est implicite, il faut donc la déclarer. Un instance profile ne référence qu'un seul rôle, et son nom est ce que l'EC2 recevra à l'étape suivante.

Toujours dans main.tf, ajoutez :

resource "aws_iam_instance_profile" "lab03_profile" {
name = "lab03-ec2-profile"
role = aws_iam_role.lab03_role.name
}

L'instance profile :

  • Est le lien entre rôle IAM et EC2
  • Contient exactement un rôle, limite qu'AWS ne relève pas ; un même rôle peut en revanche figurer dans plusieurs instance profiles
  • Est attaché à une instance, pas directement au rôle

Dernière étape, celle qui relie tout. La data source aws_ami évite de coder un identifiant d'AMI en dur, qui change à chaque région et à chaque publication d'image ; most_recent = true retient la plus récente parmi celles qui correspondent au filtre, et owners restreint la recherche au compte officiel de Canonical pour ne pas récupérer une image publique tierce. L'argument iam_instance_profile attend le nom du profil, pas son ARN.

Toujours dans main.tf, ajoutez :

# Lire l'AMI Ubuntu la plus récente
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical (Ubuntu)
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd/ubuntu-jammy-22.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "lab03_vm" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
iam_instance_profile = aws_iam_instance_profile.lab03_profile.name
tags = {
Name = var.instance_name
}
}

Clé :

  • iam_instance_profile = aws_iam_instance_profile.lab03_profile.name : l'EC2 hérite des permissions du profil

Ces sorties ne sont pas décoratives : elles servent de vérification. En affichant l'ARN du rôle, celui du profil et surtout instance_iam_instance_profile, vous prouvez que l'association a eu lieu côté instance, sans ouvrir la console. Les outputs sont aussi ce qu'une autre stack lira via terraform_remote_state.

Créez outputs.tf :

output "iam_role_arn" {
description = "ARN of the IAM role"
value = aws_iam_role.lab03_role.arn
}
output "iam_role_name" {
description = "Name of the IAM role"
value = aws_iam_role.lab03_role.name
}
output "iam_policy_arn" {
description = "ARN of the IAM policy"
value = aws_iam_policy.s3_read_policy.arn
}
output "instance_profile_arn" {
description = "ARN of the instance profile"
value = aws_iam_instance_profile.lab03_profile.arn
}
output "instance_id" {
description = "EC2 Instance ID"
value = aws_instance.lab03_vm.id
}
output "instance_arn" {
description = "EC2 Instance ARN"
value = aws_instance.lab03_vm.arn
}
output "instance_iam_instance_profile" {
description = "IAM instance profile name attached to the instance"
value = aws_instance.lab03_vm.iam_instance_profile
}

Terraform charge automatiquement terraform.tfvars s'il existe, sans option de ligne de commande. Ce fichier reprend ici les valeurs par défaut, ce qui rend le paramétrage visible en un seul endroit quand vous changez de région ou de nom de rôle. Ne le committez pas s'il finit par contenir des valeurs propres à un compte.

Créez terraform.tfvars :

aws_region = "us-east-1"
instance_name = "lab03-instance-with-iam"
instance_type = "t3.micro"
role_name = "lab03-ec2-role"

Les trois premières commandes forment la séquence standard : init télécharge le provider AWS, validate contrôle la syntaxe et les références sans appeler AWS, apply crée réellement les cinq ressources. L'option -auto-approve saute la confirmation manuelle, ce qui convient à un lab mais reste à proscrire sur une infrastructure réelle. Les deux commandes AWS CLI de la dernière étape confirment côté fournisseur ce que les outputs annoncent côté Terraform.

  1. Initialiser :

    Fenêtre de terminal
    terraform init
  2. Valider :

    Fenêtre de terminal
    terraform validate
  3. Appliquer :

    Fenêtre de terminal
    terraform apply -auto-approve

    Sortie attendue (fin) :

    Apply complete! Resources: 5 added, 0 changed, 0 destroyed.
    Outputs:
    iam_policy_arn = "arn:aws:iam::123456789012:policy/lab03-s3-read-policy"
    iam_role_arn = "arn:aws:iam::123456789012:role/lab03-ec2-role"
    iam_role_name = "lab03-ec2-role"
    instance_arn = "arn:aws:ec2:us-east-1:123456789012:instance/i-046ec132d2c5984af"
    instance_iam_instance_profile = "lab03-ec2-profile"
    instance_id = "i-046ec132d2c5984af"
    instance_profile_arn = "arn:aws:iam::123456789012:instance-profile/lab03-ec2-profile"
  4. Vérifier dans la console AWS (optionnel) :

    Fenêtre de terminal
    # Voir le rôle créé
    aws iam get-role --role-name lab03-ec2-role
    # Voir l'instance profile
    aws iam get-instance-profile --instance-profile-name lab03-ec2-profile

Maintenant que l'infrastructure existe, revenons sur les deux points qui provoquent le plus d'erreurs quand on écrit sa propre policy : la confusion entre les deux natures de policy attachées à un rôle, et l'intérêt réel de générer le JSON depuis HCL.

Un rôle porte deux catégories de policy qui ne répondent pas à la même question, et les inverser produit une erreur difficile à lire. La trust policy est déclarée dans assume_role_policy ; les policies de permissions sont attachées séparément, comme à l'étape 6.

TypeQuestion à laquelle elle répondOù elle se déclare
Trust policyqui peut endosser ce rôle ?l'argument assume_role_policy du rôle
Policy de permissionsquelles actions le rôle autorise-t-il ?une ressource aws_iam_policy, attachée au rôle

Le vocabulaire d'AWS ajoute une nuance qui déroute, et qu'il vaut mieux connaître avant de lire sa documentation : la trust policy est une resource-based policy, puisqu'elle est portée par le rôle lui-même, tandis que la policy S3 de l'étape 6 est une identity-based policy, attachée à une identité. Dans ce lab, les deux questions reçoivent des réponses distinctes :

  • Trust policy du rôle : « les EC2 peuvent l'assumer »
  • Policy de permissions, la policy S3 : « lire depuis S3 et décrire les instances »

Cette data source génère du JSON IAM à partir de HCL lisible. Sans elle, il faudrait écrire ce document à la main :

{
"Version": "2012-10-17",
"Statement": [
{
"Effect": "Allow",
"Action": ["s3:GetObject", "s3:ListBucket"],
"Resource": ["arn:aws:s3:::*", "arn:aws:s3:::*/*"]
}
]
}

Avec aws_iam_policy_document, c'est généré automatiquement et testable.

Le code de ce guide privilégie la lisibilité pédagogique, pas la rigueur d'un compte de production : la policy S3 y couvre tous les buckets. Les quatre règles ci-dessous montrent comment resserrer ce périmètre avant de réutiliser ce code ailleurs.

Une policy s'écrit en listant les actions dont l'application a besoin, jamais en partant de * pour retirer ensuite. IAM refuse par défaut : tout ce que vous n'accordez pas explicitement est déjà interdit, il n'y a donc rien à gagner à élargir « au cas où ».

# ❌ Trop permissif
actions = ["*"]
# ✅ Spécifique
actions = [
"s3:GetObject",
"s3:ListBucket"
]

Limiter les actions ne suffit pas si elles s'appliquent à tout le compte. Sur S3, il faut deux ARN distincts : celui du bucket pour ListBucket, et celui des objets, suffixé par /*, pour GetObject. Oublier le second donne une erreur d'accès sur les objets alors que la liste fonctionne.

# ❌ Tout
resources = ["*"]
# ✅ Spécifique
resources = [
"arn:aws:s3:::my-bucket",
"arn:aws:s3:::my-bucket/*"
]

Un rôle partagé finit toujours par accumuler les permissions de tous ses usages, et plus personne n'ose en retirer une. Un rôle par service garde un périmètre lisible et rend la révocation possible : supprimer le rôle n'affecte que la charge de travail concernée.

# ❌ Un rôle pour tout
role_name = "general-role"
# ✅ Rôles spécialisés
resource "aws_iam_role" "ec2_s3_role" { ... }
resource "aws_iam_role" "lambda_dynamo_role" { ... }

Une policy sans explication devient intouchable au bout de six mois, faute de savoir quel service en dépend. Renseignez l'argument description de la ressource aws_iam_policy, comme à l'étape 5, et commentez les statement dont l'intention n'est pas évidente.

Attention au bon endroit : la data source n'accepte pas d'argument description, terraform validate répond An argument named "description" is not expected here. L'intention se documente donc par un commentaire et par le sid de chaque statement, la description allant sur la ressource aws_iam_policy.

data "aws_iam_policy_document" "example" {
# Le commentaire porte l'intention : la data source n'a pas de description
statement {
sid = "ReadProdBucketOnly"
actions = ["s3:GetObject"]
resources = ["arn:aws:s3:::prod-data/*"]
}
}
resource "aws_iam_policy" "example" {
name = "lab03-s3-read-prod"
description = "Lecture seule du bucket prod-data, consommée par le rôle EC2"
policy = data.aws_iam_policy_document.example.json
}

Deux familles d'erreurs dominent : les permissions de l'utilisateur qui exécute Terraform, qui n'a pas forcément le droit de créer des rôles, et les collisions de noms, IAM refusant deux rôles homonymes dans un même compte. Le dernier cas du tableau est plus sournois, car terraform apply réussit alors que l'instance ne peut rien faire.

SymptômeCauseSolution
Error: User is not authorized to perform: iam:CreateRoleUtilisateur AWS sans permissions IAMDemander IAMFullAccess ou permissions iam:CreateRole, iam:CreatePolicy, etc.
Error: EntityAlreadyExists: Role with name lab03-ec2-role already existsRôle existe déjàChanger role_name ou terraform destroy d'abord
EC2 créée mais profil absentTiming ou oubli iam_instance_profileAjouter iam_instance_profile = aws_iam_instance_profile.lab03_profile.name
Policy attachée mais EC2 ne peut pas lire S3Trust policy manquanteVérifier que assume_role_policy permet EC2

L'instance EC2 est facturée tant qu'elle tourne, même sur un type éligible à l'offre gratuite, dès que le quota mensuel est dépassé. Détruisez donc l'ensemble dès la fin du lab : Terraform supprime les cinq ressources dans l'ordre inverse de leur création, en détachant la policy avant de supprimer le rôle. L'opération est irréversible.

Fenêtre de terminal
terraform destroy -auto-approve

Sortie attendue :

Destroy complete! Resources: 5 destroyed.

Nettoyez les fichiers :

Fenêtre de terminal
rm -rf .terraform* terraform.tfstate*

aws_iam_policy_document est une data source locale : elle n'interroge rien, elle fabrique du JSON. Vous composez la chaîne complète, policy, rôle, instance profile, instance, puis vous prouvez dans l'état structuré que la trust policy et les permissions empruntent deux chemins distincts, confusion qui coûte cher en production.

  1. IAM = sécurité AWS, toujours utiliser le moindre privilège
  2. Policy document génère du JSON depuis du HCL lisible
  3. Rôle IAM = conteneur de permissions avec trust policy
  4. Instance profile = lien entre rôle et EC2
  5. Trust policy répond « qui peut endosser ce rôle ? », et c'est une resource-based policy
  6. Policy de permissions répond « quelles actions sont autorisées ? », et c'est une identity-based policy
  7. Attacher plutôt que d'intégrer : utilisez aws_iam_role_policy_attachment pour la réutilisabilité

Ce site vous est utile ?

Sachez que moins de 1% des lecteurs soutiennent ce site.

Je maintiens ce site gratuitement, sans publicité, sans profilage et sans compte à créer. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

Le formulaire ne s'affiche pas ? Ouvrir Ko-fi dans un onglet.

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn