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: 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-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-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:
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 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. - Every internal
sourceline 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.