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.
- Terraform Module Governance
- GRINNTEC: 3-Tier Terraform Module Taxonomy
- GRINNTEC: Platform Engineering
- GRINNTEC: Service Principal Vending
- GRINNTEC: Tenant Root Bootstrap
- GRINNTEC: Subscription Vending
- Terraform GitHub repo structure
- Microsoft CAF — module design
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-groupandterraform-azurerm-resource-lock; - the
CanNotDeletelock 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 onmain. 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 withapply-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 tomainvia 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:
- no solution models the service yet, and the resource is simple and standalone; and
- 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
sourceatmain. - Checkov's
CKV_TF_1(it wants a registry version constraint) is skipped centrally inci-templates/config/checkov.yaml— these are git sources, and the ref pin is controlled and auditable in review. No inline# checkov:skipcomments. - 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.