Skip to main content

Command Palette

Search for a command to run...

Terramate : de l'orchestrateur Terraform au Platform Engineering

*Ou pourquoi j'ai arrêté de souffrir avec mes dossiers d'infra*

Updated
31 min read
Terramate : de l'orchestrateur Terraform au Platform Engineering

Quand on commence à gérer de l'infrastructure avec Terraform, les débuts sont agréables. Un module, un state, quelques variables. Puis le projet grandit. Trois environnements. Cinq équipes. Deux cloud providers. Et là, les premières douleurs apparaissent : comment éviter de dupliquer la configuration du backend dans chaque dossier ? Comment ne lancer que les stacks qui ont changé dans une Merge Request ? Comment permettre à un développeur de provisionner son propre bucket S3 sans qu'il ait à ouvrir la documentation du provider ?

Terragrunt a longtemps été la réponse standard au premier problème. Il apporte le DRY via find_in_parent_folders() et un système d'includes hiérarchiques. Mais la détection des changements n'est pas native, et il n'existe pas de couche "self-service" pour les développeurs.

Terramate propose une réponse différente, organisée en deux niveaux — tous deux intégrés dans le même outil :

  1. Un orchestrateur IaC — pour éliminer la duplication, générer du code et détecter les changements
  2. Catalyst — l'ensemble des fonctionnalités Platform Engineering de Terramate (components, bundles, scaffolding) pour fournir une interface self-service aux équipes

Dans cet article, je garde cette distinction Terramate / Catalyst pour nommer clairement les deux niveaux, même si c'est le même outil. On va les explorer à partir d'exemples concrets tirés d'un monorepo réel.


Partie 1 — Terramate IaC : l'orchestrateur

1.1 La notion de stack

En Terramate, une stack est simplement un dossier contenant un fichier stack {} avec un identifiant unique (UUID). C'est le marqueur qui indique à Terramate "ici commence une unité d'infrastructure indépendante".

# stacks/ctf/prod/bucket/mon-site/stack.tm.hcl
stack {
  id   = "20910e70-71f8-4be4-9117-3d49d5c60053"
  tags = ["google", "bucket", "needs_project"]
}

Les tags jouent un rôle central : ils vont conditionner ce que Terramate génère pour cette stack — quel provider, quel backend, quels outils de qualité. On y reviendra.

Note sur les tags : ce pattern (utiliser des tags pour conditionner la génération) est ma façon de faire — c'est l'approche que je trouve la plus lisible et la plus maintenable. Mais Terramate est flexible : on peut très bien conditionner la génération via des globals, la structure de dossiers, des attributs de stack ou n'importe quelle combinaison. Il n'y a pas de bonne ou mauvaise approche, tout dépend de votre contexte.

La hiérarchie de dossiers suit un pattern lisible : stacks/{équipe}/{environnement}/{type-ressource}/{nom}/. C'est la carte de votre infrastructure, navigable dans n'importe quel explorateur de fichiers.

Important : la structure de dossiers ne détermine pas l'ordre d'exécution. Terramate peut l'utiliser comme base d'orchestration, mais ce n'est pas l'approche recommandée. L'ordre d'exécution se déclare explicitement via les tags before et after — indépendamment de la hiérarchie de fichiers. On y reviendra en détail à la section 1.6.

graph TD
    A[stacks/] --> B[ctf/]
    A --> C[dsi/]
    B --> D[prod/]
    B --> E[staging/]
    D --> F[bucket/mon-site]
    D --> G[project/my-gcp-project]
    C --> H[prod/]
    H --> I[iam/...]
    H --> J[apps/...]

Comparé à Terragrunt : en Terragrunt, chaque dossier dispose d'un terragrunt.hcl avec du contenu (includes, locals, inputs). En Terramate, le stack.tm.hcl est souvent minimal — juste un UUID et des tags. La configuration vient d'ailleurs.

Un avantage souvent sous-estimé : Terramate ne ferme pas la porte au Terraform "classique". Vous pouvez ajouter n'importe quel fichier .tf supplémentaire dans une stack — pour surcharger un comportement généré, ajouter une ressource spécifique, ou coller un bout de code legacy pendant une migration. Terramate les prend en compte sans configuration supplémentaire. C'est ce qui rend la migration progressive réellement possible.

Astuce — compatibilité Terraform/OpenTofu : Terramate fonctionne avec les deux. Dans cet article les exemples utilisent OpenTofu (tofu), mais un simple changement de binaire dans les scripts suffit pour rester sur Terraform. Le choix n'impacte pas l'architecture.


1.2 Les globals : une seule source de vérité

Les globals sont des variables partagées entre toutes les stacks. Ils s'organisent en namespaces hiérarchiques et constituent le point d'entrée unique pour les versions de providers, les configurations cloud, les adresses de backends.

# terramate/globals/versions.tm.hcl

globals "terraform" {
  # renovate: datasource=github-releases depName=hashicorp/opentofu
  version = "~> 1.10"
}

globals "providers" "google" {
  source  = "hashicorp/google"
  # renovate: datasource=terraform-provider depName=google
  version = "~> 7.0"
}

globals "modules" "bucket" {
  source  = "terraform-google-modules/cloud-storage/google"
  # renovate: datasource=terraform-module depName=terraform-google-modules/cloud-storage/google
  version = "12.3.0"
}

Les commentaires # renovate: ne sont pas décoratifs. Un bot comme Renovate parse ces annotations et propose automatiquement des MRs pour mettre à jour les versions. Une seule ligne à merger, et toutes les stacks voient la mise à jour au prochain terramate generate.

Les globals peuvent être définis à n'importe quel niveau de la hiérarchie — racine, équipe, environnement — et sont hérités par les stacks enfants. Un global défini plus bas dans l'arborescence surcharge celui défini plus haut.

# terramate/globals/gcp.tm.hcl

globals "gcp" "region" {
  region = "europe-west1"
}

globals "gcp" "backend" {
  bucket = "mon-projet-terraform-states"
  prefix = "states/siteflow"
}

On accède à ces valeurs via global.gcp.region, global.providers.google.version, etc.

Surcharger un global au plus près de la stack

Un global défini plus bas dans l'arborescence prend la priorité sur celui défini plus haut. Ça signifie qu'on peut mettre des globals directement dans le fichier stack.tm.hcl pour surcharger uniquement ce qui change pour cette stack — sans toucher aux valeurs par défaut du reste du repo.

Exemple : toutes les stacks utilisent europe-west1 par défaut, mais un bucket spécifique doit être dans une autre région :

# stacks/ctf/prod/bucket/us-assets/stack.tm.hcl
stack {
  id   = "a1b2c3d4-..."
  tags = ["google", "bucket", "needs_project"]
}

# Surcharge locale — uniquement pour cette stack
globals "gcp" "region" {
  region = "us-central1"
}

Le template providers.tm.hcl continue d'utiliser global.gcp.region sans modification. Pour cette stack, il lit us-central1. Pour toutes les autres, europe-west1. Pas de condition dans le template, pas de variable supplémentaire.

C'est à mon sens l'un des patterns les plus puissants de Terramate : les exceptions se gèrent à la source, pas dans les templates.


1.3 Les templates et generate_hcl

C'est là que Terramate devient vraiment intéressant. Les templates permettent de générer des fichiers Terraform à partir des globals et des tags de la stack — sans les écrire à la main.

Voici comment fonctionne la génération du providers.tf :

# terramate/templates/providers.tm.hcl
generate_hcl "providers.tf" {
  lets {
    has_google = tm_contains(terramate.stack.tags, "google")
    has_gitlab = tm_contains(terramate.stack.tags, "gitlab")
  }

  content {
    terraform {
      required_version = global.terraform.version

      tm_dynamic "required_providers" {
        attributes = tm_merge(
          let.has_google ? {
            google = {
              source  = global.providers.google.source
              version = global.providers.google.version
            }
          } : {},
          let.has_gitlab ? {
            gitlab = {
              source  = global.providers.gitlab.source
              version = global.providers.gitlab.version
            }
          } : {}
        )
      }

      tm_dynamic "backend" {
        condition = let.has_google
        labels    = ["gcs"]
        content {
          bucket = global.gcp.backend.bucket
          prefix = "\({global.gcp.backend.prefix}/\){terramate.stack.id}"
        }
      }
    }

    tm_dynamic "provider" {
      condition  = let.has_google
      labels     = ["google"]
      attributes = {
        region = global.gcp.region
      }
    }
  }
}

Le mécanisme est élégant :

  1. On lit les tags de la stack (tm_contains(terramate.stack.tags, "google"))
  2. On construit conditionnellement les blocs via tm_dynamic et les fonctions tm_*
  3. Terramate génère le fichier providers.tf dans chaque dossier de stack

Si la configuration du backend change, on modifie le template une fois — et toutes les stacks se mettent à jour au prochain terramate generate.

Astuce — utilisez terramate.stack.id pour le prefix de votre state. Remarquez dans l'exemple ci-dessus : prefix = "\({global.gcp.backend.prefix}/\){terramate.stack.id}". En ancrant le path du state sur l'UUID de la stack plutôt que sur son chemin dans le système de fichiers, vous vous libérez de toute contrainte de structure. Renommer un dossier, déplacer une stack dans une autre équipe ou un autre environnement — le state ne bouge pas. Tant que l'UUID est stable, Terraform retrouve ses ressources.

La même logique s'applique aux outils de qualité. Un template génère .tflint.hcl et trivy.yml dans chaque stack :

# terramate/templates/quality.tm.hcl
generate_hcl ".tflint.hcl" {
  content {
    config {
      format     = "junit"
      plugin_dir = "${terramate.stack.path.to_root}/.cache/tflint"
    }
  }
}

generate_file "trivy.yml" {
  content = tm_yamlencode({
    severity  = ["HIGH", "CRITICAL"]
    exit-code = 1
  })
}

Ces templates sont importés une seule fois dans stacks/stacks.tm.hcl, ce qui les applique à toutes les stacks du projet.

Trop de templating tue le template.

La tentation est forte de tout générer : les providers, le backend, les labels, les variables, les outputs, les modules. Résistez. Plus un template est conditionnel et complexe, moins le fichier .tf généré est lisible — et vous perdez l'avantage principal de Terramate sur Terragrunt (voir le code en face).

Ce qui appartient aux templates : le boilerplate pur — providers, backend, outils de qualité. Ce sont des fichiers identiques à 95 % entre toutes les stacks, sans logique métier.

Ce qui n'appartient pas aux templates : la logique propre à une stack. Si vous vous retrouvez à écrire tm_ternary(tm_contains(terramate.stack.tags, "special-case"), ...) dans un template, posez-vous la question : est-ce que ce .tf ne serait pas plus clair écrit directement dans la stack ?

Le sweet spot : templates pour le boilerplate, globals dans le stack.tm.hcl pour les exceptions, fichiers .tf directs pour la logique spécifique.

Ces templates sont importés une seule fois dans stacks/stacks.tm.hcl, ce qui les applique à toutes les stacks du projet :

# stacks/stacks.tm.hcl
import { source = "/terramate/globals/versions.tm.hcl" }
import { source = "/terramate/globals/gcp.tm.hcl" }
import { source = "/terramate/templates/providers.tm.hcl" }
import { source = "/terramate/templates/quality.tm.hcl" }

Le cycle generate → commit

Point fondamental qui surprend souvent les nouveaux arrivants : les fichiers générés par terramate generate sont committés dans le repo. Ce ne sont pas des artefacts ignorés comme .terraform/ — ce sont des .tf normaux, visibles dans les diffs de MR, lisibles par n'importe quel outil.

Conséquence directe sur le workflow :

# Toujours dans cet ordre avant de committer
terramate fmt       # Formatte les fichiers .tm.hcl
terramate generate  # Regénère les .tf à partir des templates
git add .
git commit

En CI, la première chose que fait le pipeline est terramate generate. Si le diff n'est pas vide après cette étape, c'est que quelqu'un a commité sans générer — le job doit échouer. C'est aussi pourquoi les fichiers générés apparaissent dans les MRs : on peut reviewer ce que Terraform va réellement voir, pas juste les templates. C'est un avantage de debuggabilité considérable.

flowchart LR
    GV[globals/versions.tm.hcl] --> T
    GG[globals/gcp.tm.hcl] --> T
    T[templates/providers.tm.hcl]
    S[stack.tm.hcl<br>tags: google, bucket]
    S -->|conditions tm_contains| T
    T -->|generate_hcl| P[providers.tf]
    TQ[templates/quality.tm.hcl] -->|generate_hcl| L[.tflint.hcl]
    TQ -->|generate_file| Q[trivy.yml]

1.4 --changed : le vrai différenciateur face à Terragrunt

C'est probablement la fonctionnalité qui m'a définitivement convaincu.

terramate --changed script run plan

Cette commande analyse le diff Git et n'exécute le plan que sur les stacks qui ont effectivement changé. Mais ça va plus loin qu'un simple git diff : si on modifie un global (par exemple, la version d'un provider dans versions.tm.hcl), Terramate sait exactement quelles stacks importent ce global et les marque comme "changed". Si on modifie un template, toutes les stacks qui l'utilisent sont détectées.

Ce comportement est rendu possible par les UUIDs stables des stacks et la résolution statique des dépendances entre fichiers.

flowchart TD
    A[git diff main...HEAD] --> B{Fichiers modifiés}
    B -->|stacks/ctf/prod/bucket/mon-site| C[Stack bucket/mon-site]
    B -->|terramate/globals/versions.tm.hcl| D[Toutes les stacks<br>qui importent ce global]
    C --> E[terraform plan<br>bucket uniquement]
    D --> F[terraform plan<br>pour chaque stack affectée]

Pourquoi c'est important ? Sur un monorepo avec 300+ stacks — ce que j'opère aujourd'hui — un terraform plan complet est tout simplement impraticable. Avec --changed, une MR qui touche une seule stack ne plan que cette stack. La CI passe en 2 minutes au lieu de ne jamais finir.

En Terragrunt, la réponse habituelle est un script bash qui parse les diffs Git et appelle run-all sur les bons dossiers. C'est du code à maintenir, ça casse sur les cas limites, et ça ne gère pas les dépendances transitives (si un module partagé change, quelles stacks sont affectées ?).


1.5 Les scripts Terramate

Les scripts définissent des séquences de commandes réutilisables, exécutées dans le contexte de chaque stack. Ils acceptent des paramètres locaux via lets :

# terramate.tm.hcl
script "plan" {
  description = "Plan the infrastructure"
  lets {
    provider = "opentofu"
    binary   = "tofu"
  }
  job {
    commands = [
      ["tfswitch", "-t", let.provider],
      [let.binary, "init", "-upgrade"],
      [let.binary, "validate"],
      [let.binary, "plan", "-out", "tfplan"],
    ]
  }
}

script "apply" {
  description = "Apply the infrastructure"
  lets {
    binary = "tofu"
  }
  job {
    commands = [
      [let.binary, "init", "-upgrade"],
      [let.binary, "apply", "-auto-approve"],
    ]
  }
}

Combinés avec --changed et un task runner comme mise, on obtient un workflow complet :

# mise.toml
[tasks.plan]
description = "Perform the plan on changed stacks"
depends = ["gen"]
run = [
    "terramate --changed script run plan",
    "uv run scripts/generate_tfplan_md.py",  # Génère le rapport dans la MR GitLab
]

[tasks.apply]
description = "Apply changed stacks"
depends = ["gen"]
run = ["terramate --changed script run apply"]

1.6 Ordonnancement : l'approche par tags (recommandée)

Terramate supporte deux façons d'orchestrer l'ordre d'exécution des stacks.

L'approche naïve : se reposer sur la hiérarchie de dossiers. Terramate peut exécuter les stacks dans l'ordre alphabétique du système de fichiers. C'est simple à comprendre, mais ça crée un couplage fort entre votre organisation de fichiers et votre logique de déploiement. Déplacer une stack dans un autre dossier change son ordre d'exécution — effet de bord non intentionnel garanti.

L'approche recommandée : l'orchestration par tags. Chaque stack déclare explicitement sa position dans le graphe de dépendances, indépendamment de son emplacement dans l'arborescence :

# stacks/ctf/prod/project/my-gcp-project/stack.tm.hcl
stack {
  id   = "..."
  tags = ["google", "project"]
}
# stacks/ctf/prod/bucket/mon-site/stack.tm.hcl
stack {
  id   = "..."
  tags = ["google", "bucket", "apps"]

  after = ["tag:project"]   # S'exécute après toutes les stacks taggées "project"
}
# stacks/ctf/prod/apps/mon-app/stack.tm.hcl
stack {
  id   = "..."
  tags = ["google", "apps", "monitoring"]

  after  = ["tag:project", "tag:database"]  # Attend project ET database
  before = ["tag:monitoring"]               # Doit être avant monitoring
}

La puissance de cette approche : le tag "tag:project" dans un after désigne toutes les stacks qui portent ce tag, où qu'elles soient dans le repo. Pas besoin de connaître les chemins. Pas besoin de mettre à jour les dépendances quand on déplace des stacks.

graph LR
    P["stacks/.../project/<br>tag: project"] --> B["stacks/.../bucket/<br>tag: bucket, apps"]
    P --> DB["stacks/.../database/<br>tag: database"]
    DB --> APP["stacks/.../application/<br>tag: apps"]
    B --> APP
    APP --> MON["stacks/.../monitoring/<br>tag: monitoring"]

Terramate calcule cet ordre automatiquement :

terramate list --run-order
# stacks/.../project/my-gcp-project
# stacks/.../bucket/mon-site
# stacks/.../database/common-db
# stacks/.../application/mon-app
# stacks/.../monitoring/alerting

Pourquoi c'est plus flexible que l'approche dossiers ?

  • Une stack peut changer de dossier sans impacter son ordre d'exécution
  • Des stacks de teams ou de clouds différents partagent le même graphe de dépendances sémantique
  • La logique de déploiement est lisible dans le stack.tm.hcl, pas déduite de la position dans l'arbre

Filtrer l'exécution par tags

Les tags servent aussi de sélecteurs pour cibler un sous-ensemble de stacks lors d'une exécution :

# Plan uniquement les stacks taggées "apps" qui ont changé
terramate --changed script run --tags apps plan

# Plan toutes les stacks taggées "apps" ET "google" (ET logique avec ":")
terramate --changed script run --tags apps:google plan

# Exclure les stacks taggées "excluded" (utile pour les stacks manuelles)
terramate --changed script run --no-tags excluded plan

C'est particulièrement utile en CI/CD pour découper les pipelines : un job pour les stacks foundation et network, un autre pour apps, un dernier pour monitoring. Chaque job a son propre filtre, son propre niveau d'approbation.

Exécution parallèle

Par défaut, Terramate exécute les stacks séquentiellement dans l'ordre résolu. Pour les stacks sans dépendance entre elles, on peut paralléliser :

terramate --changed script run --parallel 5 plan

Terramate applique un modèle fork-join : les stacks indépendantes tournent en parallèle, celles qui ont des dépendances attendent leurs prérequis. Sur un monorepo de 300+ stacks, la différence sur le temps de CI est significative.

Ma recommandation personnelle : systématisez les tags dès le départ. Donnez un tag de "niveau" à chaque stack (foundation, project, network, database, apps, monitoring…). Les before/after se déclarent une fois par stack et vous n'y retouchez plus. C'est la base d'un monorepo IaC qui tient à 300+ stacks.

Pour une comparaison approfondie des deux outils sur d'autres dimensions (output sharing, environment promotion, AI readiness), l'équipe Terramate a publié un article de référence : Terramate vs Terragrunt: A 2026 Comparison.


1.7 Comparaison avec Terragrunt

Fonctionnalité Terragrunt Terramate
DRY configuration find_in_parent_folders() + includes Globals hiérarchiques avec namespacing
Génération de fichiers Via modules uniquement — le code Terraform est masqué Fichiers .tf générés et visibles dans chaque stack
Fichiers .tf additionnels dans une stack Non (un module = toute la stack) Oui — on peut ajouter des ressources à la main
Détection des changements Non native --changed natif, dépendances transitives
Exécution ordonnée dependencies {} + run-all after/before tags, --run-order
Scripts réutilisables Non script {} blocks avec lets
Intégration Renovate Oui, bien supporté Oui, via commentaires datasource
Platform Engineering Non Catalyst (components + bundles, expérimental)
Debuggabilité Difficile (code Terraform caché) Facile (code généré visible et inspecté directement)
Courbe d'apprentissage Faible (HCL standard) Moyenne — tm_*, generate_hcl, lets à apprendre

Quelques nuances importantes sur ce tableau.

Sur la courbe d'apprentissage : Terramate demande plus d'investissement initial que Terragrunt. La syntaxe HCL de base (stacks, globals) est accessible, mais les templates (generate_hcl, tm_dynamic, lets, tm_merge) ont une vraie courbe. Il y a moins d'exemples dans la communauté, moins de StackOverflow à piller.

Mais la flexibilité de l'outil est aussi sa principale source d'erreurs. Terragrunt impose une structure — vous faites comme Terragrunt, pas autrement. Terramate vous laisse concevoir votre propre architecture de templates et de globals, ce qui ouvre la porte aux erreurs de conception : un template trop complexe, une hiérarchie de globals mal pensée, des tags incohérents. Le code n'est pas obscur comme avec Terragrunt, mais il peut être mal écrit de votre faute plutôt que de celle de l'outil.

C'est pour ça que le contexte de migration est particulièrement favorable : vous partez d'une infrastructure existante avec des contraintes connues, des patterns établis. Vous n'avez pas à inventer l'architecture — vous la traduisez. Le risque de mauvaises décisions de conception est beaucoup plus faible que sur un projet greenfield où tout est à définir.

Là où Terramate gagne clairement : quand on utilise Terragrunt avec un module, tout le code Terraform est dans le module — opaque, pas directement inspecté dans le dossier de la stack. Avec Terramate, les fichiers .tf générés sont lisibles dans chaque stack. On peut les ouvrir, les inspecter, les débugger avec un terraform plan classique. Et surtout, on peut en ajouter : rien n'empêche de poser un extra.tf à côté des fichiers générés pour ajouter une ressource one-off. Terragrunt ne sait pas faire ça une fois qu'un module est en place.

Un point souvent méconnu : Terramate peut être utilisé uniquement pour sa détection de changements, en conservant Terragrunt pour tout le reste. Si votre seul point de douleur est "je dois plan tout à chaque MR", vous superposez Terramate pour la détection Git sans rien changer à votre organisation. C'est une entrée en matière à coût quasi nul.

Bootstrapper Terramate sur un repo existant : 5 minutes

C'est l'argument le plus sous-estimé de l'outil. Quelle que soit votre situation — repo Terraform vanilla ou repo Terragrunt — une seule commande suffit pour brancher Terramate :

# Sur un repo Terraform/OpenTofu existant
terramate create --all-terraform

# Sur un repo Terragrunt existant
terramate create --all-terragrunt

Ces commandes scannent récursivement le repo, détectent chaque dossier contenant de la configuration Terraform ou Terragrunt, et créent un stack.tm.hcl minimal avec UUID dans chacun. Zéro modification du code existant.

À partir de là, terramate --changed fonctionne immédiatement. Vous avez la détection des changements sans rien avoir réécrit. Les globals, templates et scripts s'ajoutent ensuite, à votre rythme.

C'est le vrai argument pour l'adoption : le coût d'entrée est quasi nul, le gain (--changed sur le CI) est immédiat.

Quelle approche choisir ?

  • Vous démarrez de zéro : Terragrunt + Terramate (détection Git uniquement) est un bon candidat. Terragrunt a une communauté large, une documentation mature, un écosystème établi.
  • Vous avez déjà Terragrunt : ne migrez pas pour migrer. terramate create --all-terragrunt + --changed en CI, et vous avez le gain principal. La migration complète peut attendre.
  • Vous partez d'une infrastructure Terraform existante : terramate create --all-terraform, commit, et vous commencez à bénéficier de la détection le jour même. Ajoutez globals et templates progressivement.

1.8 Le pipeline CI/CD avec Terramate

Voici à quoi ressemble un pipeline GitLab CI complet. C'est la concrétisation de tout ce qu'on a décrit dans cette partie.

# .gitlab-ci.yml
stages:
  - lint
  - security
  - plan
  - apply

variables:
  # --changed : uniquement les stacks modifiées
  # --no-tags=excluded : ignorer les stacks marquées manuellement
  TERRAMATE_OPTIONS: "--changed --no-tags=excluded"

# ── Sur chaque MR ────────────────────────────────────────────
lint:tflint:
  stage: lint
  script:
    - terramate generate
    - terramate $TERRAMATE_OPTIONS script run lint
  rules:
    - if: $CI_MERGE_REQUEST_IID

security:trivy:
  stage: security
  script:
    - terramate $TERRAMATE_OPTIONS script run security
  rules:
    - if: $CI_MERGE_REQUEST_IID

terramate:plan:
  stage: plan
  script:
    - terramate generate
    # Vérifie que generate n'a pas de diff non commité
    - git diff --exit-code
    - terramate $TERRAMATE_OPTIONS script run plan
    # Publie les plans en commentaire de MR
    - uv run scripts/generate_tfplan_md.py
  rules:
    - if: $CI_MERGE_REQUEST_IID

# ── Sur merge vers main ───────────────────────────────────────
terramate:apply:
  stage: apply
  script:
    - terramate generate
    - terramate $TERRAMATE_OPTIONS script run apply
  rules:
    - if: \(CI_COMMIT_BRANCH == \)CI_DEFAULT_BRANCH
  resource_group: production   # Un seul apply à la fois

Quelques points sur ce pipeline :

  • TERRAMATE_OPTIONS est défini une seule fois et réutilisé dans tous les jobs. Si on veut ajouter --parallel 5, on le change à un seul endroit.
  • git diff --exit-code après terramate generate : si quelqu'un a commité sans générer, le job plante et le diff non commité apparaît dans les logs. Discipline automatique.
  • resource_group: production sur l'apply : GitLab garantit qu'un seul apply tourne à la fois sur la branche principale. Pas de race condition si deux MRs mergent rapidement.
  • Le --no-tags=excluded permet de marquer certaines stacks comme "hors pipeline automatique" — utile pour les stacks sensibles qui nécessitent un apply manuel.

Partie 2 — Terramate Catalyst : le Platform Engineering

2.1 Le problème du développeur

Imaginons le scénario suivant : un développeur front-end a besoin d'un bucket GCS pour héberger son application statique. Il ouvre un ticket à l'équipe infra, qui le prend en charge "quand elle a le temps". Ce cycle ticket → implémentation → review → deploy peut prendre plusieurs jours.

Platform Engineering est la discipline qui consiste à renverser ce modèle : l'équipe infra fournit une interface — des abstractions bien définies, avec des guardrails de sécurité — et les développeurs s'auto-servent.

Terramate intègre nativement ce qu'on appelle Catalyst : l'ensemble des fonctionnalités orientées Platform Engineering. Deux concepts centraux : les components et les bundles.

Tout ce qui suit est 100% CLI open-source. Terramate existe en deux versions : le CLI (gratuit, tout ce qu'on a vu jusqu'ici et tout ce qui suit) et Terramate Cloud (payant, qui ajoute drift detection, dashboards de déploiement, policy enforcement et synchronisation des statuts). Aucune des fonctionnalités décrites dans cet article ne nécessite Terramate Cloud.

À noter sur la maturité de Catalyst : cette partie de Terramate est encore en phase expérimentale. En pratique, certains cas complexes — compositions avancées, imports conditionnels entre bundles, reconfiguration d'une stack existante — peuvent se heurter à des limitations ou des comportements non documentés. L'outil évolue vite, mais gardez à l'esprit que vous êtes sur du terrain moins stabilisé que la Partie 1. L'approche fonctionne bien pour les cas standards ; pour des besoins très spécifiques, il reste parfois plus simple de créer la stack à la main.

Catalyst + Internal Developer Platform : cette architecture se combine naturellement avec un IDP (Backstage, Cortex, Port…). Les bundles peuvent être exposés comme des "templates de service" dans votre catalogue, et les développeurs déclenchent le scaffolding depuis l'IDP sans jamais ouvrir un terminal. Terramate devient le moteur de génération derrière l'interface. Attention toutefois à ne pas sur-architecturer : si votre équipe est petite ou que les stacks sont peu nombreuses, le CLI direct reste la solution la plus simple.

Astuce — Terramate UI : le scaffolding interactif, la reconfiguration d'une stack existante, et la promotion entre environnements passent par l'interface graphique Terramate UI, lancée via terramate ui. C'est cette commande — et non un sous-commande CLI — qui orchestre la création et la gestion des bundles.


2.2 Les components

Un component est un bloc réutilisable d'infrastructure avec une interface définie (inputs typés) et une logique de génération HCL. Conceptuellement, c'est une "brique" que les bundles assemblent.

Il est organisé en deux fichiers :

L'interface — ce que le consommateur voit :

# terramate/components/corp/gcp/bucket/v1/component.tm.hcl
define component metadata {
  class       = "corp/gcp/bucket/v1"
  version     = "1.0.0"
  name        = "GCP Bucket"
}

define component {
  input "name" {
    type        = string
    description = "Bucket name"
  }

  input "storage_class" {
    type        = string
    description = "Bucket storage class"
    default     = "STANDARD"
  }

  input "public" {
    type        = bool
    description = "Rendre le bucket public"
    default     = false
  }

  input "admin_users" {
    type        = set(string)
    default     = []
    description = "Utilisateurs avec droits admin sur le bucket"
  }
}

L'implémentation — le Terraform qui sera généré :

# terramate/components/corp/gcp/bucket/v1/bucket.tm.hcl
generate_hcl "main.tf" {
  lets {
    admin_iam_users = [for u in component.input.admin_users.value : "user:${u}"]
    is_public       = tm_ternary(component.input.public.value, ["allUsers"], [])
  }

  content {
    module "bucket" {
      source  = global.modules.bucket.source
      version = global.modules.bucket.version

      names         = [component.input.name.value]
      project_id    = local.gcp_project_id
      location      = global.gcp.region
      storage_class = component.input.storage_class.value

      public_access_prevention = "enforced"
      set_admin_roles          = true
      set_viewer_roles         = true

      admins  = let.admin_iam_users
      viewers = let.is_public
    }
  }
}

Deux points importants :

  • Le component n'est pas une stack — c'est une brique assemblée par un bundle
  • Le versioning (/v1/) permet de faire évoluer l'interface sans casser les consommateurs existants
terramate/components/corp/gcp/bucket/
├── v1/
│   ├── component.tm.hcl    # Interface (inputs, metadata)
│   └── bucket.tm.hcl       # Génération HCL
└── v2/                     # Évolution possible sans breaking change
    ├── component.tm.hcl
    └── bucket.tm.hcl

2.3 Les bundles

Un bundle est un scaffolding interactif qui :

  1. Pose des questions à l'utilisateur via des prompts
  2. Compose plusieurs components
  3. Génère le dossier de stack complet, prêt à être appliqué

Voyons le bundle bucket en détail.

Les inputs — les questions posées à l'utilisateur :

# terramate/bundles/corp/gcp/bucket/v1/inputs.tm.hcl
define "bundle" {
  input "name" {
    type    = string
    prompt  { text = "Nom du bucket" }
  }

  input "project" {
    type      = bundle("corp/gcp/project/v1")  # Référence un autre bundle !
    immutable = true
    prompt    { text = "Projet GCP" }
  }

  input "public" {
    type    = bool
    default = false
    prompt  { text = "Bucket public ?" }
  }
}

type = bundle("corp/gcp/project/v1") est particulièrement puissant : l'utilisateur sélectionne un projet GCP existant (lui-même créé par un bundle "project"), et ses exports sont disponibles dans tout le bundle courant. Le bundle cascade les dépendances automatiquement.

Les lets — valeurs calculées :

# terramate/bundles/corp/gcp/bucket/v1/lets.tm.hcl
define bundle lets {
  team        = bundle.input.project.value.export.team.value
  name        = "\({let.team}-\){bundle.environment.id}-${bundle.input.name.value}"
  path_prefix = "/stacks/\({let.team}/\){bundle.environment.id}/bucket/${tm_slug(bundle.input.name.value)}"
}

La définition de la stack générée :

# terramate/bundles/corp/gcp/bucket/v1/bundle.tm.hcl
define bundle stack "bucket" {
  metadata {
    path = "${bundle.let.path_prefix}"
    tags = ["google", "bucket", "needs_project"]
  }

  component "labels" {
    source = "/terramate/components/corp/gcp/labels/v1"
    inputs = {
      name        = bundle.let.name
      team        = bundle.let.team
      environment = bundle.environment.id
    }
  }

  component "project" {
    source = "/terramate/components/corp/gcp/project/v1"
    inputs = {
      name = bundle.input.project.value.export.name.value
    }
  }

  component "bucket" {
    source = "/terramate/components/corp/gcp/bucket/v1"
    inputs = {
      name   = bundle.let.name
      public = bundle.input.public.value
    }
  }
}

Le résultat : le scaffolding se déclenche depuis Terramate UI (terramate ui), qui propose une interface graphique pour sélectionner le bundle, renseigner les prompts et valider la génération. Le résultat : un dossier stacks/{team}/{env}/bucket/{nom}/ généré avec tous les fichiers Terraform, les providers, les labels, les outils de qualité — tout.

À côté des fichiers .tf générés, Terramate crée également un fichier d'instance du bundle. Ce fichier peut être en YAML ou en HCL selon le format choisi. Voici à quoi il ressemble en YAML :

# stacks/ctf/prod/bucket/toto/bundle.tm.yml
apiVersion: terramate.io/cli/v1
kind: BundleInstance
metadata:
  name: toto
  uuid: 25408a6a-8713-4a94-a586-55f8962bc3bb
spec: {}
environments:
  prod:
    source: /terramate/bundles/corp/gcp/bucket/v1
    inputs:
      # tmdoc: Name of the bucket to create
      name: toto
      # tmdoc: GCP project
      project: my-gcp-project

Ce fichier est la mémoire du bundle : il enregistre le source, les inputs fournis au moment du scaffolding, et l'environnement cible. C'est ce qui permet à terramate ui de reconfigurer une stack existante (modifier les inputs et regénérer) ou de la promouvoir vers un autre environnement, sans repartir de zéro. Il est commité dans le repo avec le reste de la stack.

flowchart TD
    DEV["👨‍💻 Développeur<br>terramate ui"] --> B[Bundle corp/gcp/bucket/v1]

    B -->|"Prompt: Nom ?"| U1["mon-site"]
    B -->|"Prompt: Projet GCP ?"| U2["my-project → export: team=ctf"]
    B -->|"Prompt: Public ?"| U3["true"]

    B --> C1["Component labels/v1<br>→ labels.tf"]
    B --> C2["Component project/v1<br>→ data_project.tf"]
    B --> C3["Component bucket/v1<br>→ main.tf"]

    C1 --> S["stacks/ctf/prod/bucket/mon-site/"]
    C2 --> S
    C3 --> S

    S --> F1[providers.tf]
    S --> F2[main.tf]
    S --> F3[labels.tf]
    S --> F4[.tflint.hcl]
    S --> F5[trivy.yml]

2.4 Les exports entre bundles

Les bundles exposent des données pour d'autres bundles via les exports :

# terramate/bundles/corp/gcp/project/v1/exports.tm.hcl
define bundle {
  export "name" {
    value = bundle.let.name
  }

  export "team" {
    value = bundle.input.team.value
  }

  export "environment" {
    value = bundle.environment.id
  }
}

Cela permet de construire des abstractions composables : un bucket référence un projet, un projet peut référencer une organisation, etc. Les dépendances entre ressources sont encodées dans le type des inputs, pas dans des scripts de déploiement.


2.5 Les environnements et la chaîne de promotion

Les environnements Terramate sont une fonctionnalité exclusive à Catalyst — ils n'ont aucun effet sur les stacks créées manuellement. Leur rôle : définir les cibles de déploiement disponibles et encoder la chaîne de promotion entre elles.

Définition dans terramate.tm.hcl

environment {
  id          = "dev"
  name        = "Development"
}

environment {
  id           = "staging"
  name         = "Staging"
  promote_from = "dev"       # dev → staging
}

environment {
  id           = "prod"
  name         = "Production"
  promote_from = "staging"   # staging → prod
}

promote_from définit la chaîne : dev → staging → prod. C'est une déclaration linéaire — pas de branchements, pas de conditions.

Réduire le bruit et centraliser la configuration

Sans environnements, chaque bundle devrait demander à l'utilisateur : "Dans quel environnement déploies-tu ?" — un prompt de plus, une source d'erreur de plus. Avec les environnements définis, ce choix est fait en amont dans l'UI : Terramate injecte automatiquement bundle.environment.id dans tout le contexte du bundle.

Ça permet de centraliser dans le bundle toute la logique liée à l'environnement, sans la déléguer à l'utilisateur :

# terramate/bundles/corp/gcp/bucket/v1/lets.tm.hcl
define bundle lets {
  # Le nom et le chemin intègrent automatiquement l'environnement
  name        = "\({let.team}-\){bundle.environment.id}-${bundle.input.name.value}"
  path_prefix = "/stacks/\({let.team}/\){bundle.environment.id}/bucket/${tm_slug(bundle.input.name.value)}"
}
# Le label "environment" est passé au component sans prompt utilisateur
component "labels" {
  source = "/terramate/components/corp/gcp/labels/v1"
  inputs = {
    name        = bundle.let.name
    environment = bundle.environment.id   # automatique
  }
}

L'utilisateur ne saisit que ce qui est vraiment variable — le nom du bucket, le projet. Tout ce qui dépend de l'environnement (nommage, chemin, labels, potentiellement taille des instances ou politique de rétention) est géré dans le bundle. Un seul endroit à maintenir, un seul endroit à auditer.

Utilisation avec les bundles

Quand on scaffold un bundle via terramate ui, on choisit l'environnement cible. Ce choix est enregistré dans le bundle.tm.yml de la stack :

environments:
  dev:                                      # Environnement dans lequel cette stack vit
    source: /terramate/bundles/corp/gcp/bucket/v1
    inputs:
      name: mon-site
      project: my-gcp-project-dev

La promotion

Depuis terramate ui, on peut promouvoir une stack : Terramate relit le bundle.tm.yml, applique la même configuration (même bundle, mêmes inputs de base) à l'environnement suivant dans la chaîne, et génère le dossier de stack correspondant pour cet environnement.

flowchart LR
    D["stacks/ctf/dev/bucket/mon-site\nbundle.tm.yml → dev"] 
    -->|"terramate ui\nPromote"| S["stacks/ctf/staging/bucket/mon-site\nbundle.tm.yml → staging"]
    -->|"terramate ui\nPromote"| P["stacks/ctf/prod/bucket/mon-site\nbundle.tm.yml → prod"]

C'est le workflow Platform Engineering complet : un développeur scaffold en dev, valide, et demande la promotion vers staging puis prod — sans jamais réécrire de configuration. L'équipe infra contrôle la chaîne via les promote_from, et peut ajouter des gates (approbation manuelle, tests) à chaque étape dans le CI.

2.6 Ce que ça change pour les équipes

Avec cette architecture, le workflow complet devient :

  1. L'équipe infra définit les components, bundles et la chaîne d'environnements — une fois
  2. Le développeur lance terramate ui, sélectionne le bundle, choisit l'environnement dev, répond aux prompts, commit le dossier généré
  3. La CI détecte que la nouvelle stack est "changed" et lance un terraform plan ciblé
  4. Merge → apply automatique sur dev
  5. Promotion : le développeur demande une promotion vers staging depuis terramate ui, la CI rejoue le cycle
  6. Idem pour prod, avec validation humaine si souhaité

Le développeur n'a jamais eu à ouvrir la documentation du provider GCS. L'équipe infra garde le contrôle via les components : bonnes pratiques intégrées, sécurité par défaut, convention de nommage imposée.

sequenceDiagram
    participant Dev as 👨‍💻 Développeur
    participant TM as Terramate Catalyst
    participant Git as GitLab / Git
    participant CI as CI/CD

    Dev->>TM: terramate ui → sélection bundle corp/gcp/bucket/v1
    TM->>Dev: Prompts interactifs (nom, projet, options)
    Dev->>TM: Réponses
    TM->>Git: Génère stacks/ctf/prod/bucket/mon-site/
    Dev->>Git: git commit + push + MR
    Git->>CI: Trigger pipeline
    CI->>CI: terramate --changed script run plan
    Note over CI: Uniquement la nouvelle stack
    CI->>Git: Rapport de plan dans la MR
    Git->>CI: Merge → apply

Conclusion

Terramate répond à deux besoins distincts, adoptables indépendamment.

La Partie 1 seule suffit si votre problème est : "trop de duplication, CI qui plan tout à chaque commit." Une commande (terramate create --all-terraform) suffit à démarrer, et le gain est immédiat. Sur un monorepo de 300+ stacks, passer d'un plan global de 45 minutes à un plan ciblé de 2 minutes n'est pas une amélioration marginale — c'est ce qui rend le workflow viable.

La Partie 1 + Catalyst fait sens quand plusieurs équipes consomment de l'infrastructure et qu'on veut leur donner de l'autonomie sans perdre le contrôle. L'équipe infra passe de "prestataire Terraform à la demande" à "fournisseur d'une API d'infrastructure". C'est du Platform Engineering concret, pas du Platform Engineering en PowerPoint.

Retours d'expérience :

  • La partie Catalyst (components + bundles) est encore expérimentale. Elle fonctionne bien pour les cas standards, moins bien pour les edge cases complexes. Si vous bloquez sur quelque chose, créer la stack à la main reste une sortie valide.
  • La courbe d'apprentissage des templates est réelle, et la flexibilité de l'outil est à double tranchant : moins de code obscur que Terragrunt, mais plus de latitude pour faire des erreurs de conception. Dans un contexte de migration, c'est plus facile — vous traduisez une architecture existante plutôt que d'en inventer une. En greenfield, prenez le temps de poser les fondations (tags, hiérarchie de globals, conventions de templates) avant de scaler.
  • La communauté est plus petite que Terragrunt. La documentation s'améliore, mais certains comportements s'apprennent encore par l'expérience.
  • Tout ce qui est décrit ici est CLI open-source gratuit. Terramate Cloud est une option, pas un prérequis.

Ce que j'aurais voulu savoir avant de commencer :

Committez les fichiers générés. Systématisez les tags de niveau dès le premier jour. Utilisez stack.id pour le state. Ne sur-engineerez pas les templates — le code direct dans les stacks est souvent plus clair. Et si vous venez de Terragrunt, terramate create --all-terragrunt + --changed en CI, c'est déjà 80% de la valeur en une journée.

Pour aller plus loin : la documentation officielle, les exemples Catalyst et le repo d'exemples Terramate sont les meilleures ressources pour démarrer.


Le code de cet article est inspiré d'un dépôt réel. Les noms de projets et identifiants ont été anonymisés.