Terraform / OpenTofu - Infrastructure as Code
- 1. Terraform ou OpenTofu
- 2. Modèle d'exécution et concepts
- 3. Structure de projet et modules
- 4. Providers et verrouillage
- 5. État distant, verrouillage et chiffrement
- 6. Workspaces ou répertoires par environnement
- 7. Variables, locals, outputs, boucles
- 8. Import, moved, removed, lifecycle
- 9. Gestion des secrets
- 10. Exemple complet : Azure (RG + VNet + VM)
- 11. Exemple complet : Proxmox (clone cloud-init)
- 12. Tests et analyse statique
- 13. Intégration continue : plan en MR, apply manuel
- 14. Dérive, refresh et opérations sur l'état
- 15. Dépannage
- 16. Checklist de production
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ère | Terraform (HashiCorp / IBM) | OpenTofu |
|---|---|---|
| Licence | BSL 1.1 depuis la 1.6 (source disponible, pas open source au sens OSI) | MPL 2.0 (open source) |
| Gouvernance | Éditeur unique | Fondation, comité technique multi-entreprises |
| Binaire / commande | terraform | tofu |
| Registre | registry.terraform.io | registry.opentofu.org (miroir des mêmes providers) |
| Chiffrement natif de l'état et du plan | Non (repose sur le chiffrement du backend) | Oui, depuis la 1.7 (bloc encryption) |
Variables dans backend / source de module | Non | Oui, depuis la 1.8 (« early evaluation ») |
for_each sur les providers | Non | Oui, depuis la 1.9 |
| Ephemeral resources, arguments write-only | Oui (1.10 / 1.11) | Pris en charge dans les versions récentes (vérifier la note de version) |
| Framework de test | terraform test (1.6+), mocks (1.7+) | tofu test |
| Offre SaaS de l'éditeur | HCP Terraform (ex-Terraform Cloud), Terraform Enterprise | Aucune ; plateformes tierces |
.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
- Figer la version Terraform utilisée et sauvegarder l'état :
terraform state pull > backup.tfstate. - Vérifier qu'aucune fonctionnalité spécifique à Terraform plus récente que la version cible d'OpenTofu n'est utilisée.
tofu init -upgrade(les providers sont résolus depuis le registre OpenTofu, le fichier de verrouillage est mis à jour).tofu plandoit afficher No changes. Toute différence est à analyser avant le premierapply.- Mettre à jour la CI (image, commande, cache des plugins) et la contrainte
required_version.
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.
| Commande | Rôle | Remarque d'exploitation |
|---|---|---|
init | Télécharge providers et modules, configure le backend | -upgrade pour monter de version, -migrate-state / -reconfigure lors d'un changement de backend |
validate | Vérifie la syntaxe et la cohérence des types | Ne contacte aucune API ; nécessite un init -backend=false |
plan | Refresh + calcul du diff | -out=tfplan pour figer le plan, -detailed-exitcode en CI |
apply | Applique un plan | Toujours appliquer un plan sauvegardé en CI : apply tfplan |
destroy | Supprime tout ce qui est dans l'état | Équivalent à apply -destroy |
state | Manipulation de l'état (list, show, mv, rm, pull, push) | Voir la section 14 |
output | Lit les sorties | -json pour un script, -raw pour une valeur seule |
console | REPL HCL sur l'état courant | Idéal pour mettre au point une expression for |
graph | Graphe de dépendances au format DOT | terraform graph | dot -Tsvg > graph.svg |
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"] }
}
}
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, pasazurerm_subnet.app_subnet). - Toujours un
descriptionet untypesur chaque variable ; desvalidationdès qu'une valeur invalide serait détectée tard par l'API. terraform fmt -recursiveetterraform-docsen 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
}
}
}
| Contrainte | Signification | Usage |
|---|---|---|
= 1.2.3 | Version exacte | Rarement utile, le lock file s'en charge |
~> 4.0 | >= 4.0, < 5.0 | Modules racines, providers stables |
~> 4.12.0 | >= 4.12.0, < 4.13.0 | Providers en 0.x ou réputés instables |
>= 3.50 | Borne basse seule | Modules 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.
| Backend | Verrouillage | Points d'attention |
|---|---|---|
azurerm | Natif (bail sur le blob) | Activer versioning + soft delete du conteneur ; authentification Entra ID plutôt que clé d'accès |
s3 | use_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_* |
http | Selon le serveur (GitLab le gère) | État géré par GitLab : simple pour une petite équipe |
pg | Verrou consultatif PostgreSQL | Pratique on-premise si une base PostgreSQL sauvegardée existe déjà |
cloud / remote | Natif | HCP 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
}
}
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
}
}
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ère | Workspaces CLI | Répertoires par environnement |
|---|---|---|
| Code identique entre environnements | Oui, forcément | Oui via modules, avec des écarts assumés possibles |
| Identifiants / abonnements distincts | Difficile (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 staging | Faible : le chemin dit l'environnement |
| Visibilité dans Git | Le workspace courant n'apparaît pas dans le code | Explicite, revue de code par environnement |
| Promotion d'un changement | Automatique (même code) | Bump de version du module, environnement par environnement |
| Cas d'usage idéal | Environnements éphémères identiques (review apps, labs) | Prod / préprod / staging avec isolation forte |
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
}
| Situation | Choix |
|---|---|
| Ressource optionnelle (0 ou 1) | count = var.enabled ? 1 : 0 (référence : one(res.x[*].id)) |
| N instances identiques interchangeables | count 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 apply | Impossible : les clés de for_each doivent être connues au plan |
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" {}
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
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
| Argument | Effet | Cas d'usage / piège |
|---|---|---|
prevent_destroy = true | Le plan échoue s'il prévoit de détruire la ressource | Bases, 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 attributs | Attributs modifiés par un autre outil (autoscaling, tags posés par une policy). all est presque toujours une erreur |
create_before_destroy = true | Crée la nouvelle instance avant de détruire l'ancienne | Certificats, groupes de répliques. Impose des noms uniques (suffixe aléatoire) |
replace_triggered_by = [...] | Remplace la ressource quand une autre change | Recréer une VM quand son script cloud-init change |
precondition / postcondition | Assertions sur les entrées / le résultat | Garde-fous métier (voir 7.6) |
resource "azurerm_mssql_database" "main" {
# ...
lifecycle {
prevent_destroy = true
ignore_changes = [tags["CostCenter"]]
}
}
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écanisme | Protège l'affichage | Absent de l'état | Remarque |
|---|---|---|---|
sensitive = true (variable / output) | Oui | Non | Indispensable mais insuffisant |
| Data source (Key Vault, Vault) | Partiel | Non | La valeur lue est stockée dans l'état |
ephemeral resources (1.10+) | Oui | Oui | Valeur disponible pendant l'exécution seulement |
Arguments write-only (*_wo, 1.11+) | Oui | Oui | Uniquement 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 tout | La 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
}
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 loginsuffit. - Proxmox : jeton d'API dédié avec rôle minimal, passé via
PROXMOX_VE_API_TOKEN. - Jamais de secret dans un
.tfvarscommitté ; ajouter*.tfvarssensibles,*.tfstate*,.terraform/ettfplanau.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
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)
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
- Quand
user_data_file_idest utilisé, les blocsuser_accountdu 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
| Outil | Ce qu'il vérifie | Quand |
|---|---|---|
terraform fmt -check -recursive | Formatage canonique | Pre-commit, CI |
terraform validate | Syntaxe, types, références | Pre-commit, CI |
tflint + ruleset du provider | Erreurs que validate ne voit pas (taille de VM inexistante, version dépréciée, variables inutilisées) | CI |
checkov / trivy config | Mauvaises configurations de sécurité (stockage public, chiffrement absent, NSG ouvert) | CI, avec liste d'exceptions justifiées |
terraform test | Comportement 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éel | CI, 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
tfplancontient des secrets en clair (sauf chiffrement OpenTofu) : durée de rétention courte, accès aux artefacts restreint. resource_groupGitLab sérialise les applies ; le verrou d'état reste la protection ultime.- L'environnement
productiondoit ê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.txtconviennent.
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
| Commande | Effet |
|---|---|
plan -refresh-only | Montre ce qui a changé dans la réalité par rapport à l'état, sans proposer de corriger |
apply -refresh-only | Accepte la réalité dans l'état (remplace l'ancien terraform refresh, déprécié) |
plan normal | Propose de ramener la réalité au code |
plan -refresh=false | Plan 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
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
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ôme | Cause probable | Correction |
|---|---|---|
| Error acquiring the state lock | Un autre plan/apply en cours, ou un job tué qui n'a pas libéré le verrou | Vé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 à lever | Bail resté actif sur le blob | az storage blob lease break --container-name tfstate --blob-name prod/network.tfstate --account-name <sa> --auth-mode login |
| Inconsistent dependency lock file | Provider ajouté ou version modifiée sans init | terraform init -upgrade, committer le lock |
| checksums recorded in the lock file en CI | Lock généré sur une autre plateforme | terraform providers lock -platform=... (section 4) |
| Provider produced inconsistent result after apply | Bug 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 attribut | Valeur 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 apply | Clés de for_each calculées à partir d'ID générés | Utiliser 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 perdu | Bloc import |
| Saved plan is stale | L'état a changé depuis le plan | Recalculer le plan |
| VM Proxmox : apply bloqué sur la création | agent.enabled = true sans qemu-guest-agent dans l'invité | Installer l'agent dans le template ; ajuster agent.timeout |
| Proxmox permission check failed | Rôle du jeton incomplet, ou jeton avec privsep=1 sans ACL propre | Ajouter le privilège manquant (cité dans l'erreur), vérifier les ACL du jeton |
| Proxmox : échec d'envoi du snippet | SSH non configuré ou stockage sans contenu snippets | Bloc 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'API | Découper l'état ; -parallelism ; ne pas abuser de -refresh=false |
| Crash du provider (plugin did not respond) | Bug, mémoire insuffisante sur le runner | TF_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_versionet dans l'image CI. - Providers contraints (
~>),.terraform.lock.hclcommitté avec les hashes de toutes les plateformes. - Modules versionnés par tag, sans bloc
provider, variables typées, décrites et validées. for_eachsur des clés stables pour tout objet identifiable ;countréservé au 0/1.- Refactorisations via
moved,import,removed; pas destate mvnon tracé. prevent_destroysur 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 (
.gitignoreen place, scan de secrets en CI). - CI authentifiée par OIDC / identité managée ; jetons Proxmox dédiés à privilèges minimaux.
sensitivesur 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/trivyen CI ;terraform testsur 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.