Aller au contenu
Infrastructure as Code medium

Séparer dev, staging et prod avec Terraform

35 min de lecture

logo terraform

Pour séparer dev, staging et prod, on écrit le code d'infrastructure une fois dans un module, puis un répertoire par environnement qui appelle ce module avec ses propres valeurs. Chaque répertoire est une configuration racine : il a son terraform init, son backend et son état. C'est la structure que recommande la documentation pour des déploiements distincts, et la seule qui permette deux jeux de droits.

Tout ce qui suit a été exécuté sur Terraform v1.15.4 : sorties, codes de retour et fichiers produits compris. Deux idées répandues n'y survivent pas, celle du bloc backend qu'on paramètre par une variable, et celle du message No configuration files qu'on attribue à terraform init.

  • Ce qu'est une configuration racine, et pourquoi trois dossiers font trois états
  • Appeler un module commun depuis plusieurs racines, sans le recopier
  • Vérifier quel état une racine pilote vraiment, sans le deviner
  • Donner un backend par environnement en gardant un code identique partout
  • Migrer un état existant vers un nouveau backend sans recréer les ressources
  • Faire circuler une valeur d'une racine à l'autre par les outputs
  • Prouver qu'un destroy ne franchit pas la frontière

Un répertoire qui contient des ressources et sur lequel vous lancez terraform init est une configuration racine. La racine est l'unité de travail de Terraform : elle porte un backend, donc un état, et un terraform apply n'agit jamais que sur le sien.

La conséquence est immédiate et souvent découverte trop tard. Trois répertoires envs/dev, envs/staging et envs/prod ne sont pas trois dossiers de rangement, ce sont trois racines, donc trois états indépendants. Aucune expression ne traverse cette frontière : module.reseau.id fonctionne entre un appelant et son module, jamais entre deux racines.

C'est précisément ce qu'on cherche. Un environnement isolé n'est pas un environnement rangé à part, c'est un environnement dont l'état, le backend et les droits d'accès sont les siens.

Le code d'infrastructure vit dans un module, écrit une seule fois. Chaque environnement se réduit alors à un appel de ce module avec ses valeurs, ce qui supprime la duplication que cette organisation pourrait faire craindre.

depot/
├── modules/plaque/ le code commun, écrit UNE fois
├── envs/dev/ racine : son init, son backend, son état
├── envs/prod/ racine : son init, son backend, son état
└── etats/ les deux fichiers d'état
  1. Écrire le module commun

    Il ne contient aucune valeur en dur : tout ce qui varie d'un environnement à l'autre devient une variable.

    modules/plaque/main.tf
    terraform {
    required_providers {
    local = {
    source = "hashicorp/local"
    version = ">= 2.5"
    }
    random = {
    source = "hashicorp/random"
    version = ">= 3.6"
    }
    }
    }
    variable "environnement" {
    description = "Nom de l'environnement servi."
    type = string
    }
    variable "repliques" {
    description = "Nombre de plaques à produire."
    type = number
    }
    resource "random_pet" "temoin" {
    length = 2
    }
    resource "local_file" "plaque" {
    count = var.repliques
    filename = "${path.root}/plaques/${var.environnement}-${count.index}.txt"
    content = "plaque ${var.environnement} numero ${count.index}\n"
    }
    output "temoin" {
    value = random_pet.temoin.id
    }
  2. Écrire la racine dev

    Le source est un chemin relatif, ce qui garde le module dans le même paquet que son appelant. Le bloc backend reste vide, et la section suivante explique pourquoi.

    envs/dev/main.tf
    terraform {
    required_version = ">= 1.11.0"
    backend "local" {}
    }
    module "plaques" {
    source = "../../modules/plaque"
    environnement = "dev"
    repliques = 1
    }
    output "temoin" {
    value = module.plaques.temoin
    }

    Le plancher >= 1.11.0 est ouvert : il énonce la version minimale exigée, pas la version employée. Les sorties de cette page viennent de 1.15.4.

  3. Écrire la racine prod

    C'est le même appel, avec d'autres valeurs. Ajouter staging plus tard ne demandera ni de toucher au module, ni de toucher aux deux racines existantes.

    envs/prod/main.tf
    module "plaques" {
    source = "../../modules/plaque"
    environnement = "prod"
    repliques = 3
    }

Chaque racine s'initialise et s'applique séparément. Les deux sorties ci-dessous ont été obtenues l'une après l'autre, dans deux répertoires différents, et leurs comptes de ressources diffèrent :

Fenêtre de terminal
cd envs/dev && terraform apply -auto-approve
module.plaques.random_pet.temoin: Creating...
module.plaques.random_pet.temoin: Creation complete after 0s [id=game-dane]
module.plaques.local_file.plaque[0]: Creating...
module.plaques.local_file.plaque[0]: Creation complete after 0s [id=98c1f556a7a612708aecf5110d8fb18c7c039588]
Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
Outputs:
temoin = "game-dane"
Fenêtre de terminal
cd ../prod && terraform apply -auto-approve
Apply complete! Resources: 4 added, 0 changed, 0 destroyed.
Outputs:
temoin = "elegant-bear"

Le random_pet de chaque environnement joue le rôle de témoin : c'est une valeur que Terraform ne sait pas recalculer. Si elle change, la ressource a été recréée, et cette page s'en servira pour prouver l'isolation autrement que par conviction.

Terraform enregistre la configuration de backend retenue dans .terraform/terraform.tfstate, à ne pas confondre avec l'état lui-même. Ce fichier est le moyen de vérifier, sans deviner, ce qu'une racine pilote réellement :

Fenêtre de terminal
jq '{type: .backend.type, config: .backend.config}' .terraform/terraform.tfstate
{
"type": "local",
"config": {
"path": "../../etats/dev.tfstate",
"workspace_dir": null
}
}

La même commande dans envs/prod rend ../../etats/prod.tfstate. Deux chemins distincts, donc deux états distincts : la séparation est constatée, pas supposée.

Un backend par environnement, sans dupliquer le code

Section intitulée « Un backend par environnement, sans dupliquer le code »

Le réflexe naturel consiste à paramétrer le chemin de l'état par une variable, pour n'écrire le bloc qu'une fois. Terraform le refuse, et c'est la contrainte qui explique tout le reste de cette section :

variable "env" {
type = string
default = "dev"
}
terraform {
backend "local" {
path = "../etats/${var.env}.tfstate"
}
}
Error: Variables not allowed
on main.tf line 8, in terraform:
8: path = "../etats/${var.env}.tfstate"
Variables may not be used here.

L'init sort en 1. La documentation énonce la règle sans détour : « A backend block cannot refer to named values (like input variables, locals, or data source attributes). » Le backend est lu avant l'évaluation des variables, il ne peut donc rien en connaître.

Deux issues existent. Dupliquer le fichier de backend dans chaque racine, ce qui fonctionne mais laisse les copies diverger, ou employer la configuration partielle, qui est la réponse officielle.

Le principe est de laisser le bloc incomplet, donc identique dans toutes les racines, et de fournir les arguments manquants au moment de l'initialisation :

terraform {
backend "local" {}
}

La première forme est un fichier de configuration, une paire par ligne, sans bloc autour :

envs/dev/backend-dev.hcl
path = "../../etats/dev.tfstate"
Fenêtre de terminal
terraform init -backend-config=backend-dev.hcl
Initializing the backend...
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.
Terraform has been successfully initialized!

La seconde forme passe les paires directement sur la ligne de commande, ce qui convient bien à une chaîne d'intégration où la valeur vient d'une variable du runner :

Fenêtre de terminal
terraform init -backend-config="path=../../etats/prod.tfstate"

Les deux ont été mesurées : elles produisent le même Successfully configured the backend "local"!, et la trace backend.config.path de la section précédente confirme que le chemin a bien été retenu.

Le scénario est celui de tout projet qui grandit : l'état est local au départ, et il doit rejoindre un backend partagé. Terraform ne le fait pas en silence, il l'annonce à chaque étape.

Au départ, la racine n'a aucun bloc backend et son état est le terraform.tfstate du répertoire. Le témoin vaut ici exact-antelope. On ajoute alors le bloc, puis on lance un plan sans réinitialiser :

Error: Backend initialization required, please run "terraform init"
Reason: Initial configuration of the requested backend "local"
Changes to backend configurations require reinitialization. This allows
Terraform to set up the new configuration, copy existing state, etc. Please run
"terraform init" with either the "-reconfigure" or "-migrate-state" flags to
use the current configuration.

Le message nomme les deux options, qui ne font pas la même chose. -migrate-state emporte l'état vers le nouveau backend, -reconfigure l'abandonne et repart d'un état vide. Le choix par défaut n'existe pas : en non interactif, Terraform refuse plutôt que de trancher pour vous.

Fenêtre de terminal
terraform init -input=false
Error: Can't ask approval for state migration when interactive input is disabled.
Please remove the "-input=false" option and try again.

C'est le comportement qu'il faut connaître avant de câbler un pipeline : une migration d'état ne se fait pas dans un init -input=false. La commande qui aboutit sans interaction porte les deux options :

Fenêtre de terminal
terraform init -migrate-state -force-copy
Initializing the backend...
Successfully configured the backend "local"! Terraform will automatically
use this backend unless the backend configuration changes.
Terraform has been successfully initialized!

Le témoin vaut toujours exact-antelope après l'opération, et un plan -detailed-exitcode sort en 0 : l'état a changé de place sans que la moindre ressource soit recréée. Détail utile pour le ménage, l'ancien terraform.tfstate n'est pas supprimé, il est vidé, mesuré à 0 octet, et un terraform.tfstate.backup apparaît à côté.

Puisque aucune expression ne traverse la frontière, la question devient : comment prod consomme une valeur produite par une racine socle ? La voie officielle est la lecture de l'état distant de l'autre racine, par ses outputs.

app/main.tf
data "terraform_remote_state" "socle" {
backend = "local"
config = {
path = "../socle/terraform.tfstate"
}
}
output "reseau_consomme" {
value = data.terraform_remote_state.socle.outputs.nom_reseau
}

L'init de la racine consommatrice signale que le mécanisme est intégré, sans provider à installer, puis la valeur du socle traverse bien :

- terraform.io/builtin/terraform is built in to Terraform
reseau_consomme = "sharing-redfish"

Le contrat entre deux racines, ce sont donc les outputs déclarés, et rien d'autre. Une valeur que le socle n'expose pas reste inaccessible, ce que Terraform dit très précisément :

Error: Unsupported attribute
on sonde.tf line 2, in output "fuite":
2: value = data.terraform_remote_state.socle.outputs.inexistant
data.terraform_remote_state.socle.outputs is object with 1 attribute "nom_reseau"
This object does not have an attribute named "inexistant".

Pourquoi les workspaces ne remplacent pas deux racines

Section intitulée « Pourquoi les workspaces ne remplacent pas deux racines »

L'argument qu'on entend le plus est ergonomique : un terraform workspace select oublié, et l'apply part dans le mauvais environnement. Il est vrai, mais il est secondaire, et il se corrige par de la discipline ou un garde-fou de pipeline.

L'argument de la documentation est structurel, et il ne se corrige pas :

Workspaces are not appropriate for system decomposition or deployments requiring separate credentials and access controls.

La raison tient en une phrase, « CLI workspaces within a working directory use the same backend ». Un seul backend, donc un seul jeu d'accès : une production qui doit s'authentifier avec un rôle différent de dev ne peut pas vivre dans un workspace CLI, quelle que soit la rigueur de l'équipe.

Cela se constate. Dans la racine prod de cette page, on crée un workspace puis on relit la configuration de backend :

Fenêtre de terminal
terraform workspace new bac-a-sable
jq '.backend.config' .terraform/terraform.tfstate
{
"path": "../../etats/prod.tfstate",
"workspace_dir": null
}

La configuration est inchangée, parce qu'elle appartient au répertoire, pas au workspace. Deux répertoires, au contraire, ce sont deux blocs backend distincts, donc deux cibles et deux jeux de droits possibles.

Le mécanisme est en place. Voici ce qui casse en pratique, chaque cas ayant été rejoué.

C'est le piège le plus coûteux, parce que la structure de répertoires paraît correcte. Deux racines dont la configuration de backend désigne le même fichier, ou le même couple bucket et key, partagent un seul état. Mesuré en faisant pointer dev et prod sur un etats/commun.tfstate :

Apply complete! Resources: 2 added, 0 changed, 0 destroyed.
temoin = "close-redfish"
Apply complete! Resources: 3 added, 0 changed, 0 destroyed.
temoin = "close-redfish"

Le témoin de prod est celui de dev : la seconde racine a repris l'état de la première. Et le mal est réciproque, puisqu'un plan dans dev veut ensuite recréer ce que prod a effacé :

Plan: 1 to add, 0 to change, 0 to destroy.

Le contrôle est celui de la section « vérifier », lancé dans chaque racine : deux backend.config identiques sont un défaut, pas une coïncidence.

Deux personnes qui appliquent la même racine en même temps ont besoin d'un verrou, sans quoi l'état se corrompt. Le point à connaître sur le backend S3 est que ce verrou n'est pas acquis par défaut.

terraform {
backend "s3" {
bucket = "terraform-state-prod"
key = "reseau/terraform.tfstate"
region = "eu-west-1"
use_lockfile = true
}
}

La documentation est explicite sur les deux points : use_lockfile contrôle le verrouillage et vaut false par défaut, et le verrouillage par table DynamoDB « is deprecated and will be removed in a future minor version ». Un exemple de backend partagé sans use_lockfile est donc incomplet, et le détail se traite dans verrouillage du state.

Le path du backend local ne vaut que pour le workspace default

Section intitulée « Le path du backend local ne vaut que pour le workspace default »

Celui-ci surprend, et il se voit très bien sur le montage de cette page. La racine prod déclare path = "../../etats/prod.tfstate". Après un terraform workspace new bac-a-sable puis un apply, l'état du nouveau workspace n'atterrit pas dans etats/ :

Fenêtre de terminal
find . -name '*.tfstate' -not -path './.terraform/*'
./terraform.tfstate.d/bac-a-sable/terraform.tfstate

Le fichier déclaré par path sert au workspace default seul, les autres allant sous terraform.tfstate.d/<nom>/, dans la racine. Un .gitignore calé sur le seul chemin déclaré laisse donc fuiter un état, et c'est une raison de plus de ne pas mélanger les deux mécanismes d'isolation.

SymptômeCause probableCorrection
Error: Variables not allowed dans un bloc backendune variable ou un local dans le blocpasser en configuration partielle, bloc vide plus -backend-config
Error: No configuration filesplan ou apply lancé hors d'une racine ; init, lui, réussit dans un répertoire videse placer dans envs/<env>/ avant la commande
Error: Backend initialization requiredbloc backend ajouté ou modifié sans initterraform init -migrate-state pour emporter l'état, -reconfigure pour l'abandonner
Can't ask approval for state migrationmigration demandée avec -input=falseajouter -migrate-state -force-copy, ou migrer hors du pipeline
Error: address argument is requiredargument requis absent d'un backend partielle fournir par -backend-config, aucune invite ne le demandera
deux environnements partagent leurs ressourcesmême path, ou même bucket et keycomparer backend.config des deux racines, ils doivent différer
un état apparaît dans terraform.tfstate.d/un workspace non default dans une racinene pas cumuler workspaces et répertoires, ou étendre le .gitignore

Le lab deux répertoires, deux backends livre un module partagé et deux racines à câbler, avec un bloc backend volontairement vide : à vous d'écrire les fichiers de configuration partielle qui donnent à chaque environnement son état. Les tests ne lisent pas votre HCL, ils lisent ce que Terraform écrit, backend.config et modules.json, puis détruisent dev dans une copie du travail pour vérifier que prod n'a pas bougé. Il se joue hors ligne, avec le backend local.

  • Un répertoire sur lequel on lance init est une racine : un backend, un état, un apply qui n'agit que sur le sien.
  • Le code commun vit dans un module appelé par chaque racine, ce qui évite la duplication que la séparation pourrait faire craindre.
  • Un bloc backend n'accepte aucune valeur nommée : mesuré, Error: Variables not allowed, code de retour 1.
  • La configuration partielle est la réponse : bloc vide identique partout, arguments fournis par -backend-config, en fichier ou en KEY=VALUE.
  • La saisie interactive annoncée par la doc n'a pas prompté en 1.15.4, même avec un TTY : fournissez la valeur.
  • .terraform/terraform.tfstate porte la configuration retenue : c'est là qu'on vérifie qu'une racine ne pilote pas l'état d'une autre.
  • Changer de backend impose un init, avec -migrate-state pour emporter l'état ou -reconfigure pour l'abandonner, et l'ancien fichier local est vidé, pas supprimé.
  • La seule passerelle entre deux racines, ce sont les outputs déclarés d'un état distant : le reste rend Error: Unsupported attribute.
  • Les workspaces d'un répertoire partagent son backend, donc ses credentials : c'est l'argument officiel, et il ne se corrige pas par la discipline.
  • Sur un backend partagé, use_lockfile vaut false par défaut, et le verrou DynamoDB est déprécié.

Les questions ci-dessous portent sur ce qui bloque en pratique : le bloc backend qui refuse une variable, la migration d'un état vers un backend partagé, et les deux racines qui finissent par écrire au même endroit.

Ce site vous est utile ?

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

Je maintiens +700 guides gratuits, sans pub ni tracking. Un soutien, même symbolique, m'aide à couvrir l'hébergement et à garder ces ressources gratuites. Merci pour votre appui.

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

Abonnez-vous et suivez mon actualité DevSecOps sur LinkedIn