Skip to content

Tiered Terraform Architecture

The Grinntec Terraform estate is split into three tiers — resource modules, solutions, and deployments — each in its own repository group, each versioned and CI-gated independently. A change to a single Azure resource cannot reach production without first passing through a pinned solution release and an MR-reviewed deployment. This is the structure that implements the principles in Terraform Module Governance.


The three tiers

Tier Repository group Unit of work Naming Released as Consumes
1 — Resource modules terraform-azure-modules/ One Azure or Entra resource terraform-azurerm-{resource}, terraform-azuread-{resource} git semver tag provider resources only
2 — Solutions terraform-solutions/ One logical service = one or more tier-1 modules plus guardrails solution-manage-{thing} git semver tag tier-1 modules, pinned ?ref=vX.Y.Z
3 — Deployments terraform-deployments/ One live state file, per subscription or workload {prefix}-{workload}-{env}-{region} nothing — this is the running state tier-2 solutions (mostly); tier-1 or raw azurerm_* where no solution exists

GitLab project scaffolding follows the same model in a separate group, terraform-gitlab-modules/ (tier 1) — bootstrap, deployment-project, and management-project modules consumed by the platform deployments.

graph TD
    subgraph T3["Tier 3 — terraform-deployments"]
        D["gt-mkdocs-prod-westeu<br/>main.tf + versions.tf + .gitlab-ci.yml"]
    end
    subgraph T2["Tier 2 — terraform-solutions"]
        S["solution-manage-resource-group<br/>@ v2.0.0"]
    end
    subgraph T1["Tier 1 — terraform-azure-modules"]
        M1["terraform-azurerm-resource-group<br/>@ v2.0.0"]
        M2["terraform-azurerm-resource-lock<br/>@ v1.0.1"]
    end

    D -->|"source = ...solution-manage-resource-group?ref=v2.0.0"| S
    S -->|"?ref=v2.0.0"| M1
    S -->|"?ref=v1.0.1"| M2
    M1 --> AZ["azurerm_resource_group"]
    M2 --> AZL["azurerm_management_lock"]

Tier 1 — Resource modules

Responsibility. Wrap exactly one resource in a stable, compliant interface. Every module has the same shape — main.tf, variables.tf, outputs.tf, versions.tf, and a README.md regenerated by terraform-docs between the BEGIN_TF_DOCS / END_TF_DOCS markers. Providers are pinned in versions.tf: azurerm ~>4.0, azuread ~>3.0, gitlab ~>17.0.

A tier-1 module carries the opinionated, secure-by-default configuration for its resource: HTTPS-only and TLS1_2 on storage, public blob access off, CAF naming (rg-{name}-{env} and friends), and the ADR-0001 provenance tag set applied automatically. Inputs are kept minimal — complexity is abstracted into sane defaults, and only the knobs a consumer genuinely needs are surfaced, each with a description (required for docs generation).

Must never. Call another module, orchestrate across resources, or hold an environment- or deployment-specific value.

Release. Tagged vX.Y.Z. Consumers pin to a tag and trust that breaking input/output changes only arrive with a major bump.


Tier 2 — Solutions

Responsibility. Compose one or more tier-1 modules into a single logical service, and be the place where organisational opinion lives. Every source is pinned to a tier-1 git tag.

solution-manage-resource-group is the smallest example, and it is deliberately not a passthrough:

  • it bundles terraform-azurerm-resource-group and terraform-azurerm-resource-lock;
  • the CanNotDelete lock is on by default — you opt out for ephemeral sandboxes, not in;
  • it forces the full ADR-0001 tag contract, which always takes precedence over caller-supplied tags;
  • it narrows the input surface to what a deployment should decide.

It exists as a solution so that every resource-owning solution can just require a resource_group_name and stay out of resource-group lifecycle entirely, instead of each one re-deciding whether and how to create one.

Solutions range from thin to thick. At the other end, solution-manage-azure-subscription composes five pinned modules (subscription, storage-account, subscription-bootstrap, gitlab-deployment-project, subscription-budget) plus a handful of own azapi_resource blocks for Defender for Cloud — one call produces one fully vended subscription. Own resource blocks in a solution are limited to glue that cannot live in a tier-1 module: name randomisers, the immutable FinOps UUID, and azapi calls that need the scope the same apply just created.

Must never. Contain backend configuration, live values, or an un-pinned (main) source.

Release. Tagged vX.Y.Z, same CI pipeline as tier 1.


Tier 3 — Deployments

Responsibility. Describe what actually exists in a given subscription. A deployment is a leaf: nothing consumes it, and it is never tagged or released.

Deployments are grouped by purpose:

  • azure-platform/ — one folder per platform concern (azure-management-groups, subscription-vending, azure-platform-identity, azure-service-principals, azure-conditional-access, azure-defender-mcsb, azure-monitoring, azure-tenant-bootstrap), each with its own scoped service principal.
  • azure-workloads/ — one folder per workload, named {prefix}-{workload}-{env}-{region}, e.g. gt-mkdocs-prod-westeu.
  • azure-service-principals/, gitlab-projects/ — cross-cutting estate.

Each folder is just main.tf, versions.tf, and .gitlab-ci.yml. versions.tf carries the azurerm backend block pointing at that subscription's isolated state storage account in rg-terraform-state, authenticated with OIDC.

Dual-lane operational pathways (Lane A vs. Lane B)

Deployments are split into two operational pathways based on risk tier and privilege requirements:

  • Lane A (Tier 0 / Control Plane): Used for foundational repositories (azure-tenant-root, azure-service-principals, azure-priv-tier-0). Automated CI runs plan-only using a scoped read-only machine identity (sp-pla-tf-*-ro). Automated apply is permanently disabled. Deployments are executed locally by an authenticated human operator with transient PIM elevation after passing automated preflight checks (preflight.ps1). See Service Principal Vending and Tenant Root Bootstrap.
  • Lane B (Tier 1 & 2 / Workloads & Bounded Platform): Used for standard workload deployments (gt-*-*) and subscription vending (subscription-vending). Standard GitOps applies: plan on MR, apply automatically on merge to main via scoped Workload Identity Federation (OIDC).

A deployment normally calls tier-2 solutions by pinned ?ref=. It may call a tier-1 module directly, or drop to a raw azurerm_* resource, where no solution exists yet — see When to bypass tier 2.

Must never. Introduce a reusable abstraction. If logic in a deployment is worth sharing, it belongs in a tier-2 solution.


When to bypass tier 2

The layering is a strong default, not a hard rule. gt-mkdocs-prod-westeu creates its resource group through solution-manage-resource-group, but calls terraform-azurerm-static-web-app (tier 1) directly and then uses a raw azurerm_static_web_app_custom_domain resource for the apex and www domains.

That is legitimate when both:

  1. no solution models the service yet, and the resource is simple and standalone; and
  2. the raw resource covers a sub-feature (here, custom-domain binding) that no module needs to model.

The signal to stop bypassing: if you find yourself writing the same direct module call or raw-resource block in a second deployment, promote it into a tier-2 solution.


Versioning and promotion

  • Tiers 1 and 2 are released only as immutable git semver tags. Consumers never point a source at main.
  • Every internal source line carries # checkov:skip=CKV_TF_1 — the check wants a registry version constraint, but these are git sources, and the ref pin is controlled and auditable in review.
  • A breaking change to a module's inputs or outputs is a major version bump, documented in the changelog.
  • Deployments upgrade deliberately: bumping a ?ref= in an MR, reviewed against the plan, merged to apply. There is no floating dependency anywhere in the estate.

CI per tier

Tiers 1 & 2 (terraform-module-pipeline.yml) Tier 3: Lane B (terraform-deployment-pipeline.yml) Tier 3: Lane A (azure-service-principals, tenant-root)
Stages secrets → validate → lint → security → docs fmt-lint-validate → security → plan → apply fmt-lint-validate → security → plan
Fix handling fmt / tflint / terraform-docs auto-commit fixes back to branch none — pre-merge checks only none — pre-merge checks only
Security checkov as JUnit artifact checkov posted as MR note checkov posted as MR note
Plan / apply n/a Plan on MR; auto-apply on main Plan on MR (*-ro identity); apply permanently disabled in CI
Gate Tagged semver release is artifact MR review & merge is gate MR review & merge + local PIM-elevated human apply with preflight.ps1

Tool versions (TF_VERSION, TFLINT_VERSION, CHECKOV_VERSION, …) are pinned in the pipeline templates and bumped deliberately, never floating.


The state-evolution tax

Inserting or reshaping a tier-2 wrapper moves resource addresses in every deployment that already has state. When solution-manage-resource-group gained its internal module "resource_group" block, deployments that had called terraform-azurerm-resource-group directly saw Terraform plan a destroy-and-recreate. The fix is a moved {} block in the deployment:

moved {
  from = module.rg_mkdocs.azurerm_resource_group.this
  to   = module.rg_mkdocs.module.resource_group.azurerm_resource_group.this
}

Related discipline: because the delete lock and the resource it protects are managed together, removing a lock and deleting the locked resource must be two separate MRs — merge the lock removal first, then submit the deletion. Never combine them in one apply.