🛠 Outils IT
🛠 102 outils 📚 53 docs
🤖 Assistant 🌙

Terraform / OpenTofu - Infrastructure as Code

1. Terraform ou OpenTofu

En août 2023, HashiCorp a fait passer Terraform (à partir de la 1.6) de la licence MPL 2.0 à la Business Source License 1.1 (BSL). La BSL autorise l'usage interne, y compris en production, mais interdit de proposer un produit concurrent des offres commerciales de HashiCorp. En réaction, la communauté a créé OpenTofu, un fork de la dernière version MPL (1.5.x), hébergé par la Linux Foundation. HashiCorp a ensuite été racheté par IBM (finalisé début 2025) ; la licence BSL de Terraform est restée en place.

Pour une équipe d'exploitation qui gère sa propre infrastructure, les deux outils sont utilisables légalement. Le choix se joue sur la gouvernance, quelques fonctionnalités exclusives et l'outillage autour (HCP Terraform, Spacelift, env0, Atlantis...).

CritèreTerraform (HashiCorp / IBM)OpenTofu
LicenceBSL 1.1 depuis la 1.6 (source disponible, pas open source au sens OSI)MPL 2.0 (open source)
GouvernanceÉditeur uniqueFondation, comité technique multi-entreprises
Binaire / commandeterraformtofu
Registreregistry.terraform.ioregistry.opentofu.org (miroir des mêmes providers)
Chiffrement natif de l'état et du planNon (repose sur le chiffrement du backend)Oui, depuis la 1.7 (bloc encryption)
Variables dans backend / source de moduleNonOui, depuis la 1.8 (« early evaluation »)
for_each sur les providersNonOui, depuis la 1.9
Ephemeral resources, arguments write-onlyOui (1.10 / 1.11)Pris en charge dans les versions récentes (vérifier la note de version)
Framework de testterraform test (1.6+), mocks (1.7+)tofu test
Offre SaaS de l'éditeurHCP Terraform (ex-Terraform Cloud), Terraform EnterpriseAucune ; plateformes tierces
Compatibilité : un projet Terraform 1.5 passe sans modification sous OpenTofu. Les fichiers d'état sont compatibles dans le sens Terraform → OpenTofu tant que les versions restent proches. Les deux branches divergent depuis 2023 : un code qui utilise une fonctionnalité exclusive (chiffrement de l'état d'un côté, certaines nouveautés 1.1x de l'autre) n'est plus portable. OpenTofu accepte aussi des fichiers .tofu qui priment sur le .tf de même nom, pratique pour maintenir une variante spécifique dans un module partagé.

1.1 Migrer de Terraform vers OpenTofu

  1. Figer la version Terraform utilisée et sauvegarder l'état : terraform state pull > backup.tfstate.
  2. Vérifier qu'aucune fonctionnalité spécifique à Terraform plus récente que la version cible d'OpenTofu n'est utilisée.
  3. tofu init -upgrade (les providers sont résolus depuis le registre OpenTofu, le fichier de verrouillage est mis à jour).
  4. tofu plan doit afficher No changes. Toute différence est à analyser avant le premier apply.
  5. Mettre à jour la CI (image, commande, cache des plugins) et la contrainte required_version.
Attention : ne jamais faire alterner les deux binaires sur un même état. Une fois qu'un état a été écrit par une version plus récente, l'autre outil peut refuser de le lire. Choisissez un outil par état et imposez-le dans la CI. Dans la suite, les commandes sont données avec terraform ; remplacez par tofu si besoin.

2. Modèle d'exécution et concepts

Terraform est déclaratif : on décrit l'état cible, l'outil calcule le diff entre trois sources et produit un plan :

  • La configuration (fichiers .tf) : ce que l'on veut.
  • L'état (terraform.tfstate) : ce que Terraform croit gérer, avec les identifiants réels (ID Azure, VMID Proxmox...) et toutes les valeurs d'attributs, y compris les secrets en clair.
  • La réalité : interrogée par les providers pendant le refresh.

Le cœur de l'outil construit un graphe de dépendances (références implicites entre ressources, plus depends_on explicite) et l'exécute en parallèle (10 opérations simultanées par défaut, -parallelism=N). Les providers sont des binaires séparés qui dialoguent avec le cœur en gRPC.

CommandeRôleRemarque d'exploitation
initTélécharge providers et modules, configure le backend-upgrade pour monter de version, -migrate-state / -reconfigure lors d'un changement de backend
validateVérifie la syntaxe et la cohérence des typesNe contacte aucune API ; nécessite un init -backend=false
planRefresh + calcul du diff-out=tfplan pour figer le plan, -detailed-exitcode en CI
applyApplique un planToujours appliquer un plan sauvegardé en CI : apply tfplan
destroySupprime tout ce qui est dans l'étatÉquivalent à apply -destroy
stateManipulation de l'état (list, show, mv, rm, pull, push)Voir la section 14
outputLit les sorties-json pour un script, -raw pour une valeur seule
consoleREPL HCL sur l'état courantIdéal pour mettre au point une expression for
graphGraphe de dépendances au format DOTterraform graph | dot -Tsvg > graph.svg
Astuce : le plan est un contrat. Ce qui est relu en revue de code doit être exactement ce qui est appliqué. D'où la règle : plan -out dans un job, relecture humaine, puis apply de ce fichier dans un second job. Un plan sauvegardé devient invalide si l'état a changé entre-temps (Terraform le détecte et refuse).

3. Structure de projet et modules

3.1 Arborescence recommandée

Séparer les modules réutilisables (sans backend, sans configuration de provider) des configurations racines (une par environnement et par périmètre, chacune avec son propre état).

infra/
├── modules/
│   ├── network/            # module réutilisable
│   │   ├── main.tf
│   │   ├── variables.tf
│   │   ├── outputs.tf
│   │   ├── versions.tf     # required_providers, sans bloc provider
│   │   └── tests/
│   │       └── network.tftest.hcl
│   └── linux-vm/
├── live/
│   ├── prod/
│   │   ├── network/        # état 1 : réseau prod
│   │   │   ├── backend.tf
│   │   │   ├── providers.tf
│   │   │   ├── main.tf
│   │   │   └── terraform.tfvars
│   │   └── app/            # état 2 : application prod
│   └── staging/
│       ├── network/
│       └── app/
├── .tflint.hcl
├── .pre-commit-config.yaml
└── .gitlab-ci.yml

3.2 Découper les états

Un état unique pour toute l'infrastructure devient vite ingérable : plan de plusieurs minutes, rayon d'impact maximal, verrou partagé par toute l'équipe. Découper selon le rythme de changement et la criticité :

  • Socle (réseau, DNS, identités) : change rarement, impact élevé.
  • Données (bases, stockage) : change rarement, destruction irréversible → prevent_destroy.
  • Applicatif (VM, conteneurs, règles) : change souvent.

Les états communiquent par sorties lues avec data "terraform_remote_state", ou mieux par des data sources qui interrogent directement le cloud (moins de couplage : la configuration consommatrice n'a pas besoin des droits de lecture sur l'état du socle, qui contient potentiellement des secrets).

3.3 Écrire un module

# modules/network/variables.tf
variable "name" {
  type        = string
  description = "Préfixe de nommage"
  validation {
    condition     = can(regex("^[a-z0-9-]{3,20}$", var.name))
    error_message = "name : 3 à 20 caractères, minuscules, chiffres et tirets."
  }
}

variable "address_space" {
  type = list(string)
}

variable "subnets" {
  description = "Sous-réseaux indexés par nom logique"
  type = map(object({
    cidr              = string
    service_endpoints = optional(list(string), [])
  }))
}

variable "tags" {
  type    = map(string)
  default = {}
}

# modules/network/outputs.tf
output "subnet_ids" {
  description = "ID de chaque sous-réseau, par nom logique"
  value       = { for k, s in azurerm_subnet.this : k => s.id }
}

Appel avec une source versionnée (tag Git ou registre privé) :

module "network" {
  source = "git::https://git.example.com/infra/tf-modules.git//network?ref=v2.3.1"

  name          = "prod"
  address_space = ["10.20.0.0/16"]
  subnets = {
    app  = { cidr = "10.20.1.0/24" }
    data = { cidr = "10.20.2.0/24", service_endpoints = ["Microsoft.Storage"] }
  }
}
Piège : un module ne doit jamais contenir de bloc provider configuré. Sinon il devient impossible de l'utiliser avec count/for_each/depends_on, et supprimer une instance du module laisse des ressources orphelines que Terraform ne sait plus détruire (provider disparu en même temps). Déclarer seulement required_providers et, si besoin d'alias, configuration_aliases.

3.4 Conventions

  • Une ressource unique dans un module se nomme this (azurerm_virtual_network.this).
  • Noms en snake_case ; pas de type répété dans le nom (azurerm_subnet.app, pas azurerm_subnet.app_subnet).
  • Toujours un description et un type sur chaque variable ; des validation dès qu'une valeur invalide serait détectée tard par l'API.
  • terraform fmt -recursive et terraform-docs en pre-commit.
  • Éviter les modules « passe-plat » qui réexposent tous les arguments d'une ressource : un module doit encoder une décision (nommage, sécurité, tags obligatoires).

4. Providers et verrouillage

# versions.tf
terraform {
  required_version = ">= 1.9, < 2.0"

  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"      # 4.x, jamais 5.0
    }
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.70"     # provider 0.x : chaque mineure peut casser
    }
  }
}
ContrainteSignificationUsage
= 1.2.3Version exacteRarement utile, le lock file s'en charge
~> 4.0>= 4.0, < 5.0Modules racines, providers stables
~> 4.12.0>= 4.12.0, < 4.13.0Providers en 0.x ou réputés instables
>= 3.50Borne basse seuleModules réutilisables (laisser l'appelant choisir)

4.1 Le fichier .terraform.lock.hcl

Créé par init, il fige la version exacte et les empreintes (hashes) de chaque provider. Il se committe avec le code ; le répertoire .terraform/, lui, ne se committe jamais.

Problème classique : un développeur sous Windows ou macOS génère le lock, qui ne contient que les hashes de sa plateforme ; la CI Linux échoue ensuite avec the cached package does not match any of the checksums recorded. Solution :

terraform providers lock \
  -platform=linux_amd64 \
  -platform=linux_arm64 \
  -platform=windows_amd64 \
  -platform=darwin_arm64

4.2 Cache et miroir

# ~/.terraformrc (Linux) ou %APPDATA%\terraform.rc (Windows)
plugin_cache_dir = "$HOME/.terraform.d/plugin-cache"

# Environnement sans Internet : miroir de fichiers
provider_installation {
  filesystem_mirror {
    path    = "/opt/terraform/providers"
    include = ["registry.terraform.io/*/*"]
  }
  direct {
    exclude = ["registry.terraform.io/*/*"]
  }
}

Le miroir se prépare avec terraform providers mirror /opt/terraform/providers depuis une machine connectée. En CI, définir TF_PLUGIN_CACHE_DIR sur un répertoire mis en cache par le runner.

4.3 Alias de providers

provider "azurerm" {
  features {}
  subscription_id = var.subscription_prod
}

provider "azurerm" {
  alias           = "hub"
  features {}
  subscription_id = var.subscription_hub
}

resource "azurerm_virtual_network_peering" "spoke_to_hub" {
  # ... ressource dans l'abonnement par défaut
}

resource "azurerm_virtual_network_peering" "hub_to_spoke" {
  provider = azurerm.hub
  # ...
}

# Passage à un module qui déclare configuration_aliases = [azurerm.hub]
module "peering" {
  source    = "../../modules/peering"
  providers = {
    azurerm     = azurerm
    azurerm.hub = azurerm.hub
  }
}

5. État distant, verrouillage et chiffrement

L'état local (terraform.tfstate dans le répertoire) est à proscrire dès qu'il y a plus d'une personne ou une CI : pas de verrou, pas d'historique, et un secret en clair sur un poste. Un backend distant doit offrir verrouillage, versionnement, chiffrement au repos et contrôle d'accès fin.

BackendVerrouillagePoints d'attention
azurermNatif (bail sur le blob)Activer versioning + soft delete du conteneur ; authentification Entra ID plutôt que clé d'accès
s3use_lockfile = true (fichier .tflock dans le bucket, 1.10+) ; DynamoDB désormais dépréciéCompatible MinIO / Ceph RGW / stockage objet S3 : options endpoints, use_path_style, skip_*
httpSelon le serveur (GitLab le gère)État géré par GitLab : simple pour une petite équipe
pgVerrou consultatif PostgreSQLPratique on-premise si une base PostgreSQL sauvegardée existe déjà
cloud / remoteNatifHCP Terraform / Terraform Enterprise uniquement

5.1 Backend azurerm

Préparer le stockage une fois, hors Terraform (ou dans une configuration « bootstrap » à état local soigneusement conservé) :

RG=rg-tfstate
SA=sttfstate$RANDOM          # nom globalement unique, minuscules
az group create -n $RG -l francecentral
az storage account create -n $SA -g $RG -l francecentral \
  --sku Standard_ZRS --min-tls-version TLS1_2 \
  --allow-blob-public-access false --allow-shared-key-access false
az storage account blob-service-properties update -n $SA -g $RG \
  --enable-versioning true --enable-delete-retention true --delete-retention-days 30
az storage container create -n tfstate --account-name $SA --auth-mode login
# Droit minimal pour l'identité de la CI :
az role assignment create --role "Storage Blob Data Contributor" \
  --assignee <objectId-identite-ci> \
  --scope $(az storage account show -n $SA -g $RG --query id -o tsv)/blobServices/default/containers/tfstate
# backend.tf
terraform {
  backend "azurerm" {
    resource_group_name  = "rg-tfstate"
    storage_account_name = "sttfstate12345"
    container_name       = "tfstate"
    key                  = "prod/network.tfstate"
    use_azuread_auth     = true   # RBAC Entra ID au lieu de la clé du compte
    # use_oidc = true             # fédération d'identité depuis la CI
  }
}

5.2 Backend S3 (AWS ou compatible)

terraform {
  backend "s3" {
    bucket       = "tfstate-example"
    key          = "prod/network.tfstate"
    region       = "eu-west-3"
    encrypt      = true
    use_lockfile = true          # verrou natif S3 (Terraform 1.10+, OpenTofu 1.10+)
    # Stockage S3 compatible (MinIO...) :
    # endpoints = { s3 = "https://s3.example.com" }
    # use_path_style              = true
    # skip_credentials_validation = true
    # skip_requesting_account_id  = true
    # skip_region_validation      = true
  }
}
Configuration partielle : les blocs backend n'acceptent pas de variables sous Terraform. Laisser le bloc vide (backend "azurerm" {}) et fournir les valeurs par terraform init -backend-config=env/prod.backend.hcl. Les identifiants passent par variables d'environnement (ARM_*, AWS_*), jamais dans les fichiers.

5.3 Chiffrement de l'état avec OpenTofu

OpenTofu (1.7+) chiffre l'état et les fichiers de plan côté client avant de les envoyer au backend. Même un administrateur du stockage ne lit alors plus les secrets contenus dans l'état.

terraform {
  encryption {
    key_provider "pbkdf2" "main" {
      passphrase = var.state_passphrase   # >= 16 caractères, fourni par la CI
    }
    # Alternatives selon la version : key_provider "aws_kms", "gcp_kms", "openbao"...

    method "aes_gcm" "main" {
      keys = key_provider.pbkdf2.main
    }

    state {
      method   = method.aes_gcm.main
      enforced = true                     # refuse d'écrire un état en clair
    }
    plan {
      method   = method.aes_gcm.main
      enforced = true
    }
  }
}

Migration d'un état existant en clair : déclarer une méthode unencrypted en fallback le temps d'un apply, puis la retirer.

    method "unencrypted" "migrate" {}

    state {
      method = method.aes_gcm.main
      fallback {
        method = method.unencrypted.migrate
      }
    }
Attention : perdre la phrase de passe ou la clé KMS = perdre l'état. Stocker la clé dans le gestionnaire de secrets de l'équipe, et prévoir la rotation (nouveau key_provider + ancien en fallback). La configuration peut aussi être fournie via la variable d'environnement TF_ENCRYPTION, ce qui évite de la versionner.

5.4 Sauvegarde de l'état

Le versionnement du stockage est la première ligne de défense. Avant toute opération manuelle (state mv, state rm, migration de backend), faire une copie explicite : terraform state pull > avant-op-$(date +%F-%H%M).tfstate — et la stocker hors du poste une fois l'opération validée (elle contient des secrets). Voir aussi Stratégie de sauvegarde.

6. Workspaces ou répertoires par environnement

Les workspaces CLI (terraform workspace new staging) stockent plusieurs états pour la même configuration et le même backend. Ils n'ont rien à voir avec les workspaces HCP Terraform, qui sont des unités complètes (variables, droits, exécutions).

CritèreWorkspaces CLIRépertoires par environnement
Code identique entre environnementsOui, forcémentOui via modules, avec des écarts assumés possibles
Identifiants / abonnements distinctsDifficile (même backend, même provider)Naturel : un backend.tf et un providers.tf par répertoire
Risque d'erreur humaineÉlevé : apply en prod alors qu'on croyait être en stagingFaible : le chemin dit l'environnement
Visibilité dans GitLe workspace courant n'apparaît pas dans le codeExplicite, revue de code par environnement
Promotion d'un changementAutomatique (même code)Bump de version du module, environnement par environnement
Cas d'usage idéalEnvironnements éphémères identiques (review apps, labs)Prod / préprod / staging avec isolation forte
Recommandation : répertoires par environnement pour les environnements durables, modules versionnés par tag, et workspaces réservés aux environnements jetables. Si vous utilisez des workspaces, ajoutez une garde : locals { env = terraform.workspace } + une validation ou un bloc check qui échoue si terraform.workspace == "default".

7. Variables, locals, outputs, boucles

7.1 Ordre de priorité des variables

Du plus faible au plus fort : default de la variable → variables d'environnement TF_VAR_nom → terraform.tfvars → terraform.tfvars.json → *.auto.tfvars (ordre alphabétique) → -var et -var-file (dans l'ordre de la ligne de commande).

7.2 Locals et outputs

locals {
  name_prefix = "${var.project}-${var.env}"
  common_tags = merge(var.tags, {
    environment = var.env
    managed_by  = "terraform"
    repo        = "infra/live"
  })
  # Aplatir une structure imbriquée pour for_each
  nsg_rules = merge([
    for nsg_name, nsg in var.nsgs : {
      for rule in nsg.rules : "${nsg_name}.${rule.name}" => merge(rule, { nsg = nsg_name })
    }
  ]...)
}

output "vm_private_ips" {
  value = { for k, nic in azurerm_network_interface.vm : k => nic.private_ip_address }
}

output "admin_password" {
  value     = random_password.admin.result
  sensitive = true   # masqué dans le plan, mais EN CLAIR dans l'état
}

7.3 for_each ou count : le piège de la réindexation

count adresse les instances par position (vm[0], vm[1]...). Retirer un élément au milieu de la liste décale tous les suivants : Terraform veut alors détruire et recréer les instances décalées.

# DANGEREUX : retirer "web02" détruit/recrée web03 (qui devient l'index 1)
variable "vms" { default = ["web01", "web02", "web03"] }
resource "proxmox_virtual_environment_vm" "web" {
  count = length(var.vms)
  name  = var.vms[count.index]
}

# CORRECT : adresse stable par clé (web["web03"])
resource "proxmox_virtual_environment_vm" "web" {
  for_each = toset(var.vms)
  name     = each.key
}
SituationChoix
Ressource optionnelle (0 ou 1)count = var.enabled ? 1 : 0 (référence : one(res.x[*].id))
N instances identiques interchangeablescount acceptable
Instances identifiables (VM nommées, sous-réseaux, utilisateurs)for_each sur une map ou un set de chaînes
Clés calculées à partir d'attributs connus après applyImpossible : les clés de for_each doivent être connues au plan
Passer de count à for_each sans tout recréer : ajouter un bloc moved par instance (voir section 8), avec from = proxmox_virtual_environment_vm.web[0] et to = proxmox_virtual_environment_vm.web["web01"].

7.4 Dynamic blocks

Pour générer des blocs imbriqués répétés (règles, disques, interfaces). À utiliser avec modération : un dynamic block rend le code plus difficile à lire que la liste explicite.

resource "azurerm_network_security_group" "app" {
  name                = "nsg-${local.name_prefix}-app"
  location            = var.location
  resource_group_name = azurerm_resource_group.this.name

  dynamic "security_rule" {
    for_each = var.inbound_rules
    iterator = rule
    content {
      name                       = rule.key
      priority                   = rule.value.priority
      direction                  = "Inbound"
      access                     = "Allow"
      protocol                   = "Tcp"
      source_port_range          = "*"
      destination_port_range     = rule.value.port
      source_address_prefixes    = rule.value.sources
      destination_address_prefix = "*"
    }
  }
}

7.5 Data sources

Une data source lit un objet existant sans le gérer. Elle est relue à chaque plan.

data "azurerm_client_config" "current" {}

data "azurerm_subnet" "shared" {
  name                 = "snet-shared"
  virtual_network_name = "vnet-hub"
  resource_group_name  = "rg-hub-network"
}

data "proxmox_virtual_environment_nodes" "all" {}
Piège : une data source qui dépend (directement ou par depends_on) d'une ressource modifiée dans le même plan est différée à l'apply ; ses attributs deviennent known after apply et peuvent provoquer en cascade des remplacements inutiles. Éviter depends_on sur les data sources et sur les modules entiers.

7.6 Validations, préconditions et checks

resource "azurerm_linux_virtual_machine" "app" {
  # ...
  lifecycle {
    precondition {
      condition     = var.env != "prod" || var.vm_size != "Standard_B1s"
      error_message = "Taille B1s interdite en production."
    }
  }
}

check "app_health" {
  data "http" "health" {
    url = "https://app.example.com/health"
  }
  assert {
    condition     = data.http.health.status_code == 200
    error_message = "Le endpoint de santé ne répond pas 200."
  }
}

Un bloc check produit un avertissement et ne bloque pas l'apply : parfait pour une vérification post-déploiement ou de dérive fonctionnelle.

8. Import, moved, removed, lifecycle

8.1 Blocs import (1.5+)

Remplacent avantageusement la commande terraform import : l'import est déclaratif, visible dans le plan, relu en MR et rejouable.

import {
  to = azurerm_resource_group.legacy
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/rg-legacy"
}

# Import en masse (1.7+)
import {
  for_each = var.existing_vms        # map nom => "node/vmid"
  to       = proxmox_virtual_environment_vm.imported[each.key]
  id       = each.value
}
# Générer un premier jet de configuration pour les ressources importées
terraform plan -generate-config-out=generated.tf
Méthode : importer, puis itérer sur le code jusqu'à ce que le plan affiche 0 to add, 0 to change, 0 to destroy en dehors des imports. Le code généré est verbeux (tous les attributs, valeurs par défaut comprises) : le nettoyer avant de committer. Une fois appliqués, les blocs import peuvent être retirés.

8.2 Blocs moved (1.1+)

Renommer une ressource, la déplacer dans un module ou passer de count à for_each sans destruction :

moved {
  from = azurerm_linux_virtual_machine.vm
  to   = module.app.azurerm_linux_virtual_machine.this
}

moved {
  from = azurerm_subnet.subnets[0]
  to   = azurerm_subnet.subnets["app"]
}

Dans un module publié, laisser les blocs moved plusieurs versions : tous les appelants ne mettent pas à jour en même temps.

8.3 Blocs removed (1.7+)

Retirer une ressource de la gestion Terraform sans la détruire (passage à un autre état, reprise par une autre équipe), de façon déclarative plutôt qu'avec state rm :

removed {
  from = azurerm_storage_account.archive

  lifecycle {
    destroy = false
  }
}

8.4 Lifecycle

ArgumentEffetCas d'usage / piège
prevent_destroy = trueLe plan échoue s'il prévoit de détruire la ressourceBases, comptes de stockage, coffres. Ne protège pas si le bloc entier est supprimé du code
ignore_changes = [tags["x"], ...]Ignore les différences sur ces attributsAttributs modifiés par un autre outil (autoscaling, tags posés par une policy). all est presque toujours une erreur
create_before_destroy = trueCrée la nouvelle instance avant de détruire l'ancienneCertificats, groupes de répliques. Impose des noms uniques (suffixe aléatoire)
replace_triggered_by = [...]Remplace la ressource quand une autre changeRecréer une VM quand son script cloud-init change
precondition / postconditionAssertions sur les entrées / le résultatGarde-fous métier (voir 7.6)
resource "azurerm_mssql_database" "main" {
  # ...
  lifecycle {
    prevent_destroy = true
    ignore_changes  = [tags["CostCenter"]]
  }
}
Remplacement forcé : l'ancien terraform taint est déprécié. Utiliser terraform plan -replace='proxmox_virtual_environment_vm.web["web01"]' (guillemets simples indispensables sous bash pour protéger les crochets et guillemets).

9. Gestion des secrets

Règle de base : tout ce que Terraform lit ou génère finit dans l'état, en clair, que la valeur soit marquée sensitive ou non. sensitive masque l'affichage dans le plan et les logs, rien de plus.

MécanismeProtège l'affichageAbsent de l'étatRemarque
sensitive = true (variable / output)OuiNonIndispensable mais insuffisant
Data source (Key Vault, Vault)PartielNonLa valeur lue est stockée dans l'état
ephemeral resources (1.10+)OuiOuiValeur disponible pendant l'exécution seulement
Arguments write-only (*_wo, 1.11+)OuiOuiUniquement sur les ressources dont le provider les propose
Chiffrement de l'état (OpenTofu)-ChiffréProtège le fichier d'état dans son ensemble
Identité managée / fédération OIDC-Pas de secret du toutLa meilleure option pour authentifier la CI

9.1 Ephemeral resources et write-only

# Générer un mot de passe sans qu'il soit écrit dans l'état
ephemeral "random_password" "db" {
  length  = 32
  special = true
}

# Lire un secret existant de façon éphémère (provider Vault récent)
ephemeral "vault_kv_secret_v2" "db_admin" {
  mount = "kv"
  name  = "prod/db-admin"
}

# Argument write-only : la valeur est transmise à l'API mais jamais stockée.
# Le *_wo_version doit être incrémenté pour déclencher une rotation.
resource "exemple_database_instance" "main" {
  # ...
  admin_password_wo         = ephemeral.random_password.db.result
  admin_password_wo_version = 1
}
À vérifier : la disponibilité des ephemeral resources et des arguments write-only dépend à la fois de la version du cœur (Terraform 1.10/1.11 ou OpenTofu récent) et de la version du provider. Le nom d'argument ci-dessus est illustratif : consultez la documentation de la ressource cible. Une valeur éphémère ne peut être référencée que dans un contexte éphémère (provider, autre ephemeral, argument write-only, output ephemeral d'un module).

9.2 Azure Key Vault

resource "random_password" "vm_admin" {
  length  = 24
  special = true
}

resource "azurerm_key_vault_secret" "vm_admin" {
  name         = "vm-app-admin"
  value        = random_password.vm_admin.result   # dans l'état : protéger l'état
  key_vault_id = data.azurerm_key_vault.platform.id
  content_type = "password"
  expiration_date = timeadd(plantimestamp(), "8760h")
  lifecycle {
    ignore_changes = [expiration_date]
  }
}

Stratégie robuste : Terraform crée le coffre et les droits (RBAC), mais les valeurs sont posées et tournées hors Terraform (pipeline dédié, rotation native). Les applications lisent le coffre via identité managée. Voir Azure et Entra ID.

9.3 Authentification des providers

  • Azure : en CI, fédération OIDC (ARM_USE_OIDC=true, ARM_CLIENT_ID, ARM_TENANT_ID, ARM_SUBSCRIPTION_ID) : aucun secret stocké. En local, az login suffit.
  • Proxmox : jeton d'API dédié avec rôle minimal, passé via PROXMOX_VE_API_TOKEN.
  • Jamais de secret dans un .tfvars committé ; ajouter *.tfvars sensibles, *.tfstate*, .terraform/ et tfplan au .gitignore.
# .gitignore
.terraform/
*.tfstate
*.tfstate.*
*.tfplan
tfplan
crash.log
override.tf
override.tf.json
*_override.tf
secrets.auto.tfvars

10. Exemple complet : Azure (RG + VNet + VM)

Configuration racine autonome : groupe de ressources, réseau virtuel, NSG restrictif, VM Linux avec clé SSH et identité managée, sans IP publique (administration via Bastion ou VPN).

# versions.tf
terraform {
  required_version = ">= 1.9"
  required_providers {
    azurerm = {
      source  = "hashicorp/azurerm"
      version = "~> 4.0"
    }
  }
  backend "azurerm" {}   # valeurs via -backend-config
}

provider "azurerm" {
  features {
    resource_group {
      prevent_deletion_if_contains_resources = true
    }
  }
  subscription_id = var.subscription_id   # obligatoire depuis azurerm 4.x (ou ARM_SUBSCRIPTION_ID)
}
# variables.tf
variable "subscription_id" { type = string }
variable "env"             { type = string }
variable "location" {
  type    = string
  default = "francecentral"
}
variable "admin_ssh_public_key" {
  type        = string
  description = "Clé publique OpenSSH de l'administrateur"
}
variable "admin_source_cidr" {
  type        = string
  description = "Plage autorisée en SSH (VPN / Bastion)"
  default     = "10.0.0.0/8"
}
variable "vm_size" {
  type    = string
  default = "Standard_B2s"
}
# main.tf
locals {
  name = "app-${var.env}"
  tags = {
    environment = var.env
    managed_by  = "terraform"
  }
}

resource "azurerm_resource_group" "this" {
  name     = "rg-${local.name}"
  location = var.location
  tags     = local.tags
}

resource "azurerm_virtual_network" "this" {
  name                = "vnet-${local.name}"
  address_space       = ["10.30.0.0/16"]
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  tags                = local.tags
}

resource "azurerm_subnet" "app" {
  name                 = "snet-app"
  resource_group_name  = azurerm_resource_group.this.name
  virtual_network_name = azurerm_virtual_network.this.name
  address_prefixes     = ["10.30.1.0/24"]
}

resource "azurerm_network_security_group" "app" {
  name                = "nsg-${local.name}-app"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  tags                = local.tags

  security_rule {
    name                       = "allow-ssh-admin"
    priority                   = 100
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "22"
    source_address_prefix      = var.admin_source_cidr
    destination_address_prefix = "*"
  }

  security_rule {
    name                       = "allow-https-vnet"
    priority                   = 110
    direction                  = "Inbound"
    access                     = "Allow"
    protocol                   = "Tcp"
    source_port_range          = "*"
    destination_port_range     = "443"
    source_address_prefix      = "VirtualNetwork"
    destination_address_prefix = "*"
  }
}

resource "azurerm_subnet_network_security_group_association" "app" {
  subnet_id                 = azurerm_subnet.app.id
  network_security_group_id = azurerm_network_security_group.app.id
}

resource "azurerm_network_interface" "vm" {
  name                = "nic-${local.name}-01"
  location            = azurerm_resource_group.this.location
  resource_group_name = azurerm_resource_group.this.name
  tags                = local.tags

  ip_configuration {
    name                          = "primary"
    subnet_id                     = azurerm_subnet.app.id
    private_ip_address_allocation = "Dynamic"
  }
}

resource "azurerm_linux_virtual_machine" "vm" {
  name                            = "vm-${local.name}-01"
  computer_name                   = "app01"
  resource_group_name             = azurerm_resource_group.this.name
  location                        = azurerm_resource_group.this.location
  size                            = var.vm_size
  admin_username                  = "azureadmin"
  disable_password_authentication = true
  network_interface_ids           = [azurerm_network_interface.vm.id]
  encryption_at_host_enabled      = false   # true si la fonctionnalité est activée sur l'abonnement
  tags                            = local.tags

  admin_ssh_key {
    username   = "azureadmin"
    public_key = var.admin_ssh_public_key
  }

  os_disk {
    caching              = "ReadWrite"
    storage_account_type = "Premium_LRS"
    disk_size_gb         = 64
  }

  source_image_reference {
    publisher = "Canonical"
    offer     = "ubuntu-24_04-lts"
    sku       = "server"
    version   = "latest"
  }

  identity {
    type = "SystemAssigned"
  }

  custom_data = base64encode(templatefile("${path.module}/cloud-init.yaml.tftpl", {
    hostname = "app01"
  }))

  boot_diagnostics {}   # compte de stockage managé

  lifecycle {
    ignore_changes = [source_image_reference[0].version]
  }
}

output "vm_private_ip" {
  value = azurerm_network_interface.vm.private_ip_address
}

output "vm_principal_id" {
  description = "À utiliser pour donner des droits RBAC (Key Vault...) à la VM"
  value       = azurerm_linux_virtual_machine.vm.identity[0].principal_id
}
# Exécution
az login
terraform init -backend-config=env/prod.backend.hcl
terraform plan  -var-file=env/prod.tfvars -out=tfplan
terraform show  tfplan            # relecture
terraform apply tfplan
Pièges Azure : modifier custom_data ou admin_ssh_key remplace la VM (lire le plan : forces replacement). Ne pas mélanger security_rule en ligne dans le NSG et des ressources azurerm_network_security_rule séparées sur le même NSG : les deux s'écrasent mutuellement à chaque apply. Même logique pour les sous-réseaux déclarés en ligne dans le VNet.

11. Exemple complet : Proxmox (clone cloud-init)

Le provider bpg/proxmox est aujourd'hui le plus complet et le plus maintenu pour Proxmox VE (l'ancien telmate/proxmox reste utilisé mais son modèle de données est différent). Prérequis : un template cloud-init (voir Proxmox) avec qemu-guest-agent installé.

11.1 Jeton d'API dédié

# Sur un nœud Proxmox : rôle limité (adapter la liste à votre version de PVE)
pveum role add TerraformProv -privs "Datastore.AllocateSpace Datastore.AllocateTemplate Datastore.Audit \
  Pool.Allocate Sys.Audit Sys.Console Sys.Modify SDN.Use \
  VM.Allocate VM.Audit VM.Clone VM.Config.CDROM VM.Config.Cloudinit VM.Config.CPU \
  VM.Config.Disk VM.Config.HWType VM.Config.Memory VM.Config.Network VM.Config.Options \
  VM.Migrate VM.PowerMgmt"
pveum user add terraform@pve
pveum aclmod / -user terraform@pve -role TerraformProv
pveum user token add terraform@pve tf --privsep 0
# -> noter l'identifiant complet terraform@pve!tf et la valeur du jeton (affichée une seule fois)
Remarque : l'envoi de fichiers snippets (cloud-init personnalisé) passe par SSH sur le nœud, pas par l'API. Le provider a alors besoin d'un compte SSH (clé via l'agent) et le stockage cible doit autoriser le type de contenu snippets. Les privilèges exacts évoluent selon les versions de PVE (ex. VM.Monitor retiré en PVE 9) : vérifier avec pveum role list.
# providers.tf
terraform {
  required_providers {
    proxmox = {
      source  = "bpg/proxmox"
      version = "~> 0.70"   # épingler la mineure testée
    }
  }
}

provider "proxmox" {
  endpoint = "https://pve01.example.internal:8006/"
  # api_token lu depuis PROXMOX_VE_API_TOKEN="terraform@pve!tf=<uuid>"
  insecure = false          # true seulement en lab avec certificat auto-signé

  ssh {
    agent    = true
    username = "terraform"  # compte SSH du nœud, pour l'envoi des snippets
  }
}
# variables.tf
variable "node"        { default = "pve01" }
variable "template_id" { default = 9000 }
variable "datastore"   { default = "local-zfs" }
variable "ssh_pubkey"  { type = string }

variable "vms" {
  type = map(object({
    vm_id  = number
    cores  = optional(number, 2)
    memory = optional(number, 2048)
    disk   = optional(number, 20)
    ip     = string          # notation CIDR, ex. 192.168.10.21/24
  }))
  default = {
    web01 = { vm_id = 210, ip = "192.168.10.21/24" }
    web02 = { vm_id = 211, ip = "192.168.10.22/24" }
    db01  = { vm_id = 220, ip = "192.168.10.30/24", memory = 8192, disk = 80 }
  }
}
# main.tf
resource "proxmox_virtual_environment_file" "user_data" {
  content_type = "snippets"
  datastore_id = "local"
  node_name    = var.node

  source_raw {
    file_name = "tf-user-data.yaml"
    data      = templatefile("${path.module}/user-data.yaml.tftpl", {
      ssh_pubkey = var.ssh_pubkey
    })
  }
}

resource "proxmox_virtual_environment_vm" "vm" {
  for_each  = var.vms

  name      = each.key
  node_name = var.node
  vm_id     = each.value.vm_id
  tags      = ["terraform", "debian"]
  on_boot   = true

  clone {
    vm_id = var.template_id
    full  = true
  }

  agent {
    enabled = true   # sans agent dans l'invité, le provider attend l'IP indéfiniment
  }

  cpu {
    cores = each.value.cores
    type  = "x86-64-v2-AES"
  }

  memory {
    dedicated = each.value.memory
  }

  disk {
    datastore_id = var.datastore
    interface    = "scsi0"
    size         = each.value.disk
    discard      = "on"
    iothread     = true
  }

  network_device {
    bridge = "vmbr0"
    model  = "virtio"
  }

  initialization {
    datastore_id = var.datastore
    ip_config {
      ipv4 {
        address = each.value.ip
        gateway = "192.168.10.1"
      }
    }
    dns {
      servers = ["192.168.10.1"]
    }
    user_data_file_id = proxmox_virtual_environment_file.user_data.id
  }

  lifecycle {
    ignore_changes = [clone]   # le template peut évoluer sans recréer les VM
  }
}

output "vm_ips" {
  value = { for k, v in proxmox_virtual_environment_vm.vm : k => v.ipv4_addresses }
}
# user-data.yaml.tftpl
#cloud-config
users:
  - name: deploy
    groups: [sudo]
    shell: /bin/bash
    sudo: "ALL=(ALL) NOPASSWD:ALL"
    ssh_authorized_keys:
      - ${ssh_pubkey}
package_update: true
packages:
  - qemu-guest-agent
runcmd:
  - systemctl enable --now qemu-guest-agent
Pièges Proxmox :
  • Quand user_data_file_id est utilisé, les blocs user_account du provider sont ignorés : tout passe par le snippet.
  • Clonage entre nœuds : le template doit être sur un stockage partagé, ou présent sur le nœud cible (sinon paramètre clone.node_name + stockage local disponible des deux côtés).
  • Le provider est en 0.x : lire le changelog avant chaque montée de mineure, certaines changent le schéma.
  • Une modification manuelle dans l'interface Proxmox (ajout de disque, changement de RAM) apparaît comme dérive au plan suivant : décider si on la réintègre dans le code ou si on l'écrase.

La configuration logicielle des VM (paquets, durcissement, services) se fait ensuite avec Ansible : Terraform pour le cycle de vie de l'infrastructure, Ansible pour la configuration. L'inventaire Ansible peut être généré depuis terraform output -json vm_ips ou par le plugin d'inventaire Proxmox.

12. Tests et analyse statique

OutilCe qu'il vérifieQuand
terraform fmt -check -recursiveFormatage canoniquePre-commit, CI
terraform validateSyntaxe, types, référencesPre-commit, CI
tflint + ruleset du providerErreurs que validate ne voit pas (taille de VM inexistante, version dépréciée, variables inutilisées)CI
checkov / trivy configMauvaises configurations de sécurité (stockage public, chiffrement absent, NSG ouvert)CI, avec liste d'exceptions justifiées
terraform testComportement d'un module (sorties, conditions)CI des modules
Policy as code (OPA / Conftest sur le plan JSON)Règles d'organisation sur le plan réelCI, avant apply

12.1 tflint

# .tflint.hcl
config {
  call_module_type = "local"
}

plugin "terraform" {
  enabled = true
  preset  = "recommended"
}

plugin "azurerm" {
  enabled = true
  version = "X.Y.Z"   # dernière version publiée du ruleset
  source  = "github.com/terraform-linters/tflint-ruleset-azurerm"
}
tflint --init
tflint --recursive --format compact

12.2 Checkov et Trivy

checkov -d . --framework terraform --compact --quiet
# Sur le plan (valeurs résolues, plus précis) :
terraform show -json tfplan > tfplan.json
checkov -f tfplan.json --framework terraform_plan

# Trivy (a absorbé tfsec)
trivy config --severity HIGH,CRITICAL .

Exception documentée directement dans le code :

resource "azurerm_storage_account" "logs" {
  #checkov:skip=CKV_AZURE_33:Logs de file queue inutiles, compte dédié aux blobs
  # trivy:ignore:AVD-AZU-0012
  # ...
}

12.3 terraform test

Fichiers *.tftest.hcl dans le module (ou sous tests/). Chaque run exécute un plan ou un apply (ressources réelles créées puis détruites) et vérifie des assertions. Les mocks (1.7+) permettent de tester sans identifiants cloud.

# modules/network/tests/network.tftest.hcl
mock_provider "azurerm" {}

variables {
  name          = "test"
  address_space = ["10.99.0.0/16"]
  subnets = {
    app = { cidr = "10.99.1.0/24" }
  }
}

run "nommage_vnet" {
  command = plan

  assert {
    condition     = azurerm_virtual_network.this.name == "vnet-test"
    error_message = "Convention de nommage du VNet non respectée."
  }
}

run "nom_invalide_refuse" {
  command = plan
  variables {
    name = "Nom_Invalide"
  }
  expect_failures = [var.name]
}
terraform test                 # tous les tests
terraform test -filter=tests/network.tftest.hcl -verbose

12.4 Pre-commit

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/antonbabenko/pre-commit-terraform
    rev: vX.Y.Z
    hooks:
      - id: terraform_fmt
      - id: terraform_validate
      - id: terraform_tflint
      - id: terraform_docs
      - id: terraform_trivy

13. Intégration continue : plan en MR, apply manuel

Principes : (1) le plan est calculé dans la MR et publié pour revue ; (2) l'apply n'a lieu qu'après fusion, déclenché manuellement, et applique le plan produit sur la branche principale ; (3) l'identité de la CI en lecture seule pour le plan des MR, en écriture uniquement pour le job d'apply sur la branche protégée. Voir aussi CI/CD et Git.

# .gitlab-ci.yml
variables:
  TF_ROOT: live/prod/network
  TF_IN_AUTOMATION: "true"
  TF_INPUT: "false"
  TF_PLUGIN_CACHE_DIR: ${CI_PROJECT_DIR}/.tf-plugin-cache

image:
  name: ${TF_IMAGE}          # image Terraform ou OpenTofu, version épinglée
  entrypoint: [""]

cache:
  key: tf-plugins-${TF_ROOT}
  paths: [.tf-plugin-cache]

stages: [validate, plan, apply]

.tf_init: &tf_init
  - mkdir -p "$TF_PLUGIN_CACHE_DIR"
  - cd "$TF_ROOT"
  - terraform init -backend-config=../../../env/prod.backend.hcl

validate:
  stage: validate
  script:
    - terraform fmt -check -recursive
    - *tf_init
    - terraform validate
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

plan:
  stage: plan
  id_tokens:
    AZURE_OIDC_TOKEN:
      aud: api://AzureADTokenExchange
  script:
    - export ARM_OIDC_TOKEN="$AZURE_OIDC_TOKEN" ARM_USE_OIDC=true
    - *tf_init
    - set +e
    - terraform plan -lock-timeout=5m -detailed-exitcode -out=tfplan
    - rc=$?
    - set -e
    - if [ "$rc" -eq 1 ]; then exit 1; fi
    - terraform show -no-color tfplan > plan.txt
    - terraform show -json tfplan > plan.json
  artifacts:
    paths: [$TF_ROOT/tfplan, $TF_ROOT/plan.txt]
    expire_in: 7 days
  rules:
    - if: $CI_PIPELINE_SOURCE == "merge_request_event"
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH

apply:
  stage: apply
  environment: production
  resource_group: tf-prod-network      # un seul apply à la fois
  needs: [plan]
  id_tokens:
    AZURE_OIDC_TOKEN:
      aud: api://AzureADTokenExchange
  script:
    - export ARM_OIDC_TOKEN="$AZURE_OIDC_TOKEN" ARM_USE_OIDC=true
    - *tf_init
    - terraform apply -lock-timeout=5m tfplan
  rules:
    - if: $CI_COMMIT_BRANCH == $CI_DEFAULT_BRANCH
      when: manual
  • -detailed-exitcode : 0 = aucun changement, 1 = erreur, 2 = changements. Permet de marquer la MR ou de déclencher une alerte de dérive.
  • L'artefact tfplan contient des secrets en clair (sauf chiffrement OpenTofu) : durée de rétention courte, accès aux artefacts restreint.
  • resource_group GitLab sérialise les applies ; le verrou d'état reste la protection ultime.
  • L'environnement production doit être protégé (approbation requise, déploiement réservé aux mainteneurs).
  • Pour poster le plan en commentaire de MR, des outils comme Atlantis ou un script appelant l'API GitLab avec plan.txt conviennent.

14. Dérive, refresh et opérations sur l'état

14.1 Détecter la dérive

La dérive (drift) est l'écart entre la réalité et le code, en général dû à une modification manuelle. Un job planifié (quotidien) suffit :

terraform plan -refresh-only -detailed-exitcode -lock=false
# 2 => la réalité diffère de l'état : alerter
CommandeEffet
plan -refresh-onlyMontre ce qui a changé dans la réalité par rapport à l'état, sans proposer de corriger
apply -refresh-onlyAccepte la réalité dans l'état (remplace l'ancien terraform refresh, déprécié)
plan normalPropose de ramener la réalité au code
plan -refresh=falsePlan rapide sans interroger les API (risqué : peut se baser sur un état obsolète)

Face à une dérive : soit le changement manuel était légitime et on le reporte dans le code, soit on l'écrase par un apply. Si un attribut est légitimement piloté ailleurs, ignore_changes.

14.2 Opérations sur l'état

terraform state list                                   # adresses gérées
terraform state list 'module.network'                  # filtrage
terraform state show 'azurerm_subnet.app'              # attributs (secrets compris)

# Renommer / déplacer (préférer un bloc moved, rejouable et relu)
terraform state mv 'azurerm_subnet.app' 'module.network.azurerm_subnet.this["app"]'

# Oublier une ressource sans la détruire (préférer un bloc removed)
terraform state rm 'proxmox_virtual_environment_vm.vm["old01"]'

# Déplacer entre deux états
terraform state pull > source.tfstate                      # dans la config source
# puis importer côté cible via un bloc import, et removed côté source

# Sauvegarde / restauration (dernier recours)
terraform state pull > backup.tfstate
terraform state push backup.tfstate                    # refusé si le serial est plus ancien
terraform state push -force backup.tfstate             # DANGER : écrase sans contrôle
Attention : toute opération state manuelle doit être faite hors CI active, par une seule personne, après sauvegarde, et suivie d'un plan qui doit montrer exactement l'effet attendu. Les blocs moved, import et removed couvrent aujourd'hui la majorité des besoins de façon traçable : réservez la CLI aux cas d'urgence.

14.3 Changer de backend

# Modifier backend.tf puis :
terraform init -migrate-state     # copie l'état de l'ancien vers le nouveau backend
# ou, pour ignorer l'ancien backend sans migrer :
terraform init -reconfigure

15. Dépannage

15.1 Journalisation

export TF_LOG=DEBUG              # TRACE, DEBUG, INFO, WARN, ERROR
export TF_LOG_PATH=./terraform-debug.log
export TF_LOG_PROVIDER=TRACE     # logs du provider seulement
export TF_LOG_CORE=WARN          # réduire le bruit du cœur
terraform plan
Attention : les logs DEBUG/TRACE contiennent les requêtes HTTP complètes, en-têtes d'authentification et secrets compris. Ne jamais les joindre tels quels à un ticket public ; les supprimer après usage.

15.2 Symptômes courants

SymptômeCause probableCorrection
Error acquiring the state lockUn autre plan/apply en cours, ou un job tué qui n'a pas libéré le verrouVérifier qu'aucune exécution n'est active (CI, collègue), puis terraform force-unlock <LOCK_ID> (ID affiché dans l'erreur). En CI, utiliser -lock-timeout=5m
Verrou azurerm impossible à leverBail resté actif sur le blobaz storage blob lease break --container-name tfstate --blob-name prod/network.tfstate --account-name <sa> --auth-mode login
Inconsistent dependency lock fileProvider ajouté ou version modifiée sans initterraform init -upgrade, committer le lock
checksums recorded in the lock file en CILock généré sur une autre plateformeterraform providers lock -platform=... (section 4)
Provider produced inconsistent result after applyBug du provider ou API qui normalise une valeur (casse, ordre)Mettre à jour le provider ; aligner la valeur sur ce que renvoie l'API ; signaler le bug
Diff perpétuel sur un attributValeur modifiée par l'API ou par une policy (tags, ordre de liste)Aligner le code, ou ignore_changes ciblé
The "for_each" value depends on resource attributes that cannot be determined until applyClés de for_each calculées à partir d'ID générésUtiliser des clés statiques (noms issus des variables) ; en dernier recours -target en deux temps
Cycle: ...Dépendance circulaire (souvent NSG ↔ règle, ou module ↔ module)Séparer en ressources distinctes, casser le cycle avec une data source
Resource already exists (Azure)Ressource créée hors Terraform ou état perduBloc import
Saved plan is staleL'état a changé depuis le planRecalculer le plan
VM Proxmox : apply bloqué sur la créationagent.enabled = true sans qemu-guest-agent dans l'invitéInstaller l'agent dans le template ; ajuster agent.timeout
Proxmox permission check failedRôle du jeton incomplet, ou jeton avec privsep=1 sans ACL propreAjouter le privilège manquant (cité dans l'erreur), vérifier les ACL du jeton
Proxmox : échec d'envoi du snippetSSH non configuré ou stockage sans contenu snippetsBloc ssh du provider, clé dans l'agent, activer snippets sur le stockage
Plan très lentÉtat trop gros, beaucoup de data sources, limites d'APIDécouper l'état ; -parallelism ; ne pas abuser de -refresh=false
Crash du provider (plugin did not respond)Bug, mémoire insuffisante sur le runnerTF_LOG=TRACE, monter la version, augmenter la RAM du runner

15.3 -target : usage exceptionnel

terraform apply -target=module.network applique un sous-ensemble du graphe. Utile pour sortir d'un cas bloquant (ressource dont dépend un for_each), mais l'état reste partiellement convergé. Toujours le faire suivre d'un plan complet, et ne jamais l'utiliser comme mode de travail courant.

16. Checklist de production

Projet et code

  • Un seul outil (Terraform ou OpenTofu) par état, version figée par required_version et dans l'image CI.
  • Providers contraints (~>), .terraform.lock.hcl committé avec les hashes de toutes les plateformes.
  • Modules versionnés par tag, sans bloc provider, variables typées, décrites et validées.
  • for_each sur des clés stables pour tout objet identifiable ; count réservé au 0/1.
  • Refactorisations via moved, import, removed ; pas de state mv non tracé.
  • prevent_destroy sur les données (bases, stockages, coffres).

État

  • Backend distant avec verrouillage, versionnement, suppression réversible, chiffrement au repos.
  • Accès à l'état restreint (RBAC), pas de clé de compte partagée ; chiffrement client si OpenTofu.
  • États découpés par environnement et par périmètre ; isolation prod / hors-prod (abonnements, identifiants).

Secrets

  • Aucun secret dans Git (.gitignore en place, scan de secrets en CI).
  • CI authentifiée par OIDC / identité managée ; jetons Proxmox dédiés à privilèges minimaux.
  • sensitive sur toutes les variables et sorties sensibles ; ephemeral / write-only quand c'est possible.
  • Artefacts de plan et logs DEBUG traités comme confidentiels.

Processus

  • fmt, validate, tflint, checkov/trivy en CI ; terraform test sur les modules.
  • Plan en MR, relu ; apply manuel du plan sauvegardé, sur branche et environnement protégés.
  • Détection de dérive planifiée avec alerte.
  • Procédure documentée pour lever un verrou et restaurer un état ; testée au moins une fois.
  • Montées de version (cœur et providers) dans des MR dédiées, avec lecture du changelog et plan sans changement attendu.
Pour aller plus loin : Ansible pour la configuration des VM créées, Kubernetes pour les charges conteneurisées, PKI et certificats pour les certificats internes des endpoints Proxmox.