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-modules/terraform-azure-modules/ One Azure or Entra resource terraform-azurerm-{resource}, terraform-azuread-{resource} git semver tag provider resources only
2 — Solutions terraform-modules/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 deployments/ One live state file, per subscription or workload {workload}-{env}-{region}-{nn} 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-modules/terraform-gitlab-modules/ (tier 1) — bootstrap, deployment-project, and management-project modules consumed by the platform deployments.

graph TD
    subgraph T3["Tier 3 — 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:

  • deployments/azure-platform/ — one project per platform concern (azure-tenant-bootstrap, azure-management-groups, subscription-vending-platform, subscription-vending-workload, azure-monitoring, …), each with its own scoped service principal.
  • deployments/azure-access-management/ — identity and privileged access (azure-service-principals, azure-privileged-access-grants, azure-privileged-access-packages, conditional access).
  • deployments/workloads/ — one project per workload subscription, named {workload}-{env}-{region}-{nn}, e.g. grinntec-website-prod-westeu-00.
  • deployments/gitlab-projects/ — the GitLab projects themselves.

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-bootstrap, azure-service-principals, azure-privileged-access-grants). Automated CI runs plan-only using a scoped read-only machine identity (sp-pla-tf-*-ro) and saves the plan on main. Automated apply is permanently disabled, and local plans are blocked. A human operator with transient PIM elevation applies the plan CI saved for the merged commit with apply-merged — never a plan made from local files. 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.
  • Checkov's CKV_TF_1 (it wants a registry version constraint) is skipped centrally in ci-templates/config/checkov.yaml — these are git sources, and the ref pin is controlled and auditable in review. No inline # checkov:skip comments.
  • 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.