3-Tier Terraform Module Taxonomy: Architecture & Governance¶
An enterprise Internal Developer Platform (IDP) requires an explicit, governed Infrastructure-as-Code (IaC) taxonomy. Splitting the estate into three distinct tiers — Resource Modules (Tier 1), Solutions (Tier 2), and Deployments (Tier 3) — establishes an immutable contract between platform engineering and workload teams. This structure enforces security baselines, minimizes cognitive load, prevents blast radius expansion, and enables friction-free self-service cloud vending.
- GRINNTEC: Platform Engineering
- GRINNTEC: Subscription Vending
- GRINNTEC: Service Principal Vending
- GRINNTEC: Tenant Root Bootstrap
- GRINNTEC: Terraform State Storage Architecture
- GRINNTEC: Access Tiering Model: EAM, NIST and Tier 0/1/2
- Tiered Terraform Architecture (Overview)
- Terraform Module Governance
- Microsoft CAF — Module Design Lifecycle
Why an IaC Taxonomy Matters in Platform Engineering¶
In early-stage cloud deployments, teams often lump all infrastructure definitions into monolithic Terraform repositories or write custom, unversioned modules for every application. Over time, this leads to:
- Inconsistent Security & Compliance Posture: One team enforces TLS 1.2+ and disables public blob endpoints; another team leaves storage accounts open to the internet.
- Untracked Blast Radii: Monolithic state files mix platform control planes with workload applications, meaning a minor application deployment can inadvertently destroy shared network or identity infrastructure.
- Cognitive Overload: Developers must understand every obscure cloud provider argument instead of consuming standard, organizational "golden paths."
- Dependency Hell & Flaky Upgrades: Unpinned floating dependencies cause downstream builds to break unexpectedly when providers release upstream breaking changes.
Platform engineering solves this by treating IaC modules as internal platform products. The 3-tier taxonomy establishes a strict separation of concerns, clear boundaries of ownership, and an auditable release lifecycle.
Architecture Tiers vs. Security (EAM) Tiers¶
Before exploring the taxonomy, it is vital to disambiguate IaC Architecture Tiers from Enterprise Access Model (EAM) Security Tiers:
flowchart LR
subgraph ARCH["IaC Architecture Tiers (Code Abstraction)"]
direction TB
T1["Tier 1: Resource Modules<br/>(Atomic building blocks)"]
T2["Tier 2: Solutions<br/>(Composed Golden Paths)"]
T3["Tier 3: Deployments<br/>(Instantiated live state)"]
T1 --> T2 --> T3
end
subgraph SEC["EAM / NIST Security Tiers (Blast Radius & Privilege)"]
direction TB
EAM0["Tier 0: Control Plane<br/>(Root identity, tenant state, keys)"]
EAM1["Tier 1: Management Plane<br/>(Hub networks, logging, policies)"]
EAM2["Tier 2: Workload Plane<br/>(Application workloads & services)"]
EAM0 -.-> EAM1 -.-> EAM2
end
| Dimension | IaC Architecture Tiers (This Guide) | Security / EAM Tiers (Access Tiering Model) |
|---|---|---|
| What it categorizes | Code abstraction, packaging, and reusability | Security boundaries, identity trust, and blast radius |
| Tier 1 | Resource Modules: Single-resource building blocks | Management Plane: Enterprise management, monitoring, hub networking |
| Tier 2 | Solutions: Composed golden paths with guardrails | Workload Plane: Applications, data stores, consumer VMs/containers |
| Tier 3 / Tier 0 | Tier 3 Deployments: Live state files in environments | Tier 0 Control Plane: Root identity, credentials, tenant bootstrap |
Both hierarchies intersect at Tier 3 (Deployments): a deployment can instantiate Tier 0 infrastructure (e.g. azure-tenant-root), Tier 1 platform infrastructure (e.g. azure-management-groups), or Tier 2 workload infrastructure (e.g. gt-mkdocs-prod-westeu).
The Three Tiers at a Glance¶
| Attribute | Tier 1 — Resource Modules | Tier 2 — Solutions | Tier 3 — Deployments |
|---|---|---|---|
| Primary Purpose | Wrap a single cloud or provider resource with opinionated defaults | Compose Tier 1 modules into coherent, governed service archetypes ("Golden Paths") | Instantiate live infrastructure in a dedicated Azure subscription or environment |
| Repository Group | terraform-azure-modules/terraform-gitlab-modules/ |
terraform-solutions/ |
terraform-deployments/azure-platform/terraform-deployments/azure-workloads/ |
| Naming Convention | terraform-azurerm-{resource}terraform-azuread-{resource} |
solution-manage-{service} |
{prefix}-{workload}-{env}-{region} |
| Unit of Work | One atomic Azure/Entra/GitLab resource | One business service / pattern | One running state file (.tfstate) |
| State File? | None (stateless definition) | None (stateless definition) | Yes (dedicated remote storage container) |
| Can Consume | Raw provider resources only | Tier 1 modules (pinned to git semver tags) | Tier 2 solutions (pinned ?ref=vX.Y.Z), or fallback to Tier 1 |
| Release Artifact | Immutable Git SemVer tag (vX.Y.Z) |
Immutable Git SemVer tag (vX.Y.Z) |
None (git commit on main applies to live cloud) |
| Primary Owner | Platform Engineering | Platform Engineering / Domain Architects | Workload Team or Platform Operations |
Tier 1 — Resource Modules¶
Responsibility¶
Tier 1 modules wrap exactly one resource in a compliant, secure-by-default interface. They shield consumers from the complexities and pitfalls of raw cloud provider APIs while enforcing baseline corporate standards.
graph TD
subgraph T1["Tier 1: terraform-azurerm-storage-account"]
V["versions.tf<br/>(azurerm ~> 4.0)"]
M["main.tf<br/>(azurerm_storage_account)"]
VAR["variables.tf<br/>(sane defaults)"]
OUT["outputs.tf<br/>(ids, endpoints)"]
end
M --> AZ["Azure Resource Manager API"]
Architectural Contract¶
- Canonical Structure: Every Tier 1 module adheres to a uniform structure:
main.tf: Contains the single target resource (e.g.azurerm_storage_account).variables.tf: Minimal, typed inputs with explicitdescriptionfields (mandated for automated documentation).outputs.tf: Exports resource attributes needed by higher tiers (IDs, names, primary endpoints). Sensitive outputs are flaggedsensitive = true.versions.tf: Pins required Terraform and provider versions using pessimistic operator constraints (e.g.azurerm ~> 4.0,azuread ~> 3.0).README.md: Auto-generated by CI viaterraform-docsbetween<!-- BEGIN_TF_DOCS -->and<!-- END_TF_DOCS -->markers.- Secure-by-Default Configuration: Security baselines are non-negotiable defaults:
- Storage accounts: TLS 1.2 minimum, HTTPS traffic only, public blob access disabled, infrastructure encryption on.
- Resource groups: Cloud Adoption Framework (CAF) naming format (
rg-{name}-{env}) and ADR-0001 provenance tagging automatically applied. - Minimal Input Surface: Do not expose all 60+ arguments of a complex Azure resource. Abstract away infrastructure minutiae and only surface what a caller legitimately needs to vary.
Hard Constraints ("Must Never")¶
- Never call another module: Tier 1 modules must never declare a
moduleblock. Orchestration belongs in Tier 2. - Never hold live or environment-specific values: No hardcoded subscription IDs, tenant IDs, or environment names.
- Never maintain remote backend state: Tier 1 modules have no state; they are purely reusable templates.
Release Governance¶
Released strictly as immutable SemVer tags (vX.Y.Z). Consumers pin to specific tags (?ref=v1.2.0). Breaking changes to inputs or outputs mandate a major SemVer bump (v2.0.0) with documented upgrade instructions.
Tier 2 — Solutions (Golden Paths)¶
Responsibility¶
Tier 2 solutions are where organizational opinion and architectural policy live. A solution composes one or more Tier 1 modules into a cohesive, ready-to-run business service or landing zone pattern.
graph TD
subgraph T2["Tier 2: solution-manage-resource-group (@ v2.0.0)"]
S_MAIN["main.tf"]
S_LOCK["Enforce CanNotDelete lock"]
S_TAGS["Enforce ADR-0001 tag contract"]
end
subgraph T1["Tier 1 Resource Modules"]
M_RG["terraform-azurerm-resource-group<br/>(@ v2.0.0)"]
M_LK["terraform-azurerm-resource-lock<br/>(@ v1.0.1)"]
end
S_MAIN -->|source = ...?ref=v2.0.0| M_RG
S_MAIN -->|source = ...?ref=v1.0.1| M_LK
M_RG --> AZ_RG["azurerm_resource_group"]
M_LK --> AZ_LK["azurerm_management_lock"]
Architectural Contract¶
- Guardrails by Default: Solutions embed compliance policies directly into the code structure:
- In
solution-manage-resource-group, anazurerm_management_lockof typeCanNotDeleteis enabled by default. Developers must explicitly passenable_delete_lock = false(e.g. for ephemeral test sandboxes) to bypass it. - In
solution-manage-azure-subscription, baseline Defender for Cloud posture plans, diagnostic logs, and FinOps metadata tags are injected automatically. - Surface Filtering: A solution reduces cognitive load by hiding Tier 1 implementation details. Consumers specify business parameters (e.g., workload name, environment, cost center), while the solution determines the underlying subnet sizing, private endpoint configuration, and lock definitions.
- Allowed Solution-Level Resources ("Glue"): Solutions should primarily compose Tier 1 modules. However, native resources are permitted when they serve strictly as integration glue that cannot logically belong to a single resource module:
random_idorrandom_stringfor collision prevention or deterministic naming.- Cost tracking metadata and immutable UUID generators.
- Native
azapi_resourcecalls that require an immediate parent ID created in the same apply phase (e.g. enabling a tenant-scoped Defender plan immediately upon subscription creation).
Hard Constraints ("Must Never")¶
- Never use unpinned module sources: Every
sourcecall must specify a pinned git release tag (?ref=vX.Y.Z). Never point to a branch (e.g.ref=main). - Never declare backend configuration: Like Tier 1, solutions are stateless platform products.
- Never allow caller tags to overwrite mandatory compliance tags: Organizational provenance tags (ADR-0001) must merge over caller-supplied tags, ensuring auditable lineage.
Release Governance¶
Versioned and released via the same automated CI pipeline as Tier 1. Tagged as immutable SemVer (vX.Y.Z).
Tier 3 — Deployments (Live State)¶
Responsibility¶
Tier 3 represents the instantiated reality of the estate. A deployment is a concrete leaf node in the dependency graph that maps code to an actual Terraform state file (.tfstate) in an isolated storage container.
graph TD
subgraph T3["Tier 3: terraform-deployments"]
DEP["gt-mkdocs-prod-westeu<br/>• main.tf<br/>• versions.tf (remote backend)<br/>• .gitlab-ci.yml"]
end
subgraph T2["Tier 2 Solutions"]
SOL_RG["solution-manage-resource-group<br/>@ v2.1.0"]
end
subgraph T1["Tier 1 Resource Modules"]
MOD_SWA["terraform-azurerm-static-web-app<br/>@ v1.4.0"]
end
DEP -->|source = ...?ref=v2.1.0| SOL_RG
DEP -->|source = ...?ref=v1.4.0 (bypass)| MOD_SWA
DEP -->|raw resource| RAW["azurerm_static_web_app_custom_domain"]
Dual-Lane Operational Pathways¶
Deployments are categorized into two operational pathways based on risk, privilege scope, and regulatory sensitivity:
flowchart TD
subgraph LANE_A["Lane A: Control Plane (Tier 0)"]
direction TB
LA_REPOS["Repositories:<br/>• azure-tenant-root<br/>• azure-service-principals<br/>• azure-priv-tier-0"]
LA_CI["CI Runner: Plan Only<br/>(sp-pla-tf-*-ro)"]
LA_GATE["Manual Security Gate<br/>preflight.ps1 + PIM Elevation"]
LA_APPLY["Local Human Apply<br/>(Time-bound, audited)"]
LA_REPOS --> LA_CI --> LA_GATE --> LA_APPLY
end
subgraph LANE_B["Lane B: Workloads & Bounded Platform (Tier 1 & 2)"]
direction TB
LB_REPOS["Repositories:<br/>• subscription-vending<br/>• azure-management-groups<br/>• gt-mkdocs-prod-westeu"]
LB_CI_PLAN["CI Runner: Plan on MR<br/>(OIDC Workload Identity)"]
LB_MERGE["Peer Review & Merge to main"]
LB_CI_APPLY["CI Runner: Automated Apply<br/>(Pipeline exclusivity)"]
LB_REPOS --> LB_CI_PLAN --> LB_MERGE --> LB_CI_APPLY
end
- Lane A (Tier 0 / Control Plane):
- Applies to tenant root bootstrap, central service principal vending, and root PIM eligibility.
- Automated CI runs plan-only using a read-only credential (
sp-pla-tf-*-ro). - CI
applyis permanently disabled in the pipeline definition. - Deployment is executed locally by an authorized, PIM-elevated human engineer following automated pre-flight checks (
preflight.ps1). See Tier 0 Pipeline Design. - Lane B (Tier 1 & 2 / Workloads & Bounded Platform):
- Applies to subscription vending, management group trees, and workload applications.
- Standard GitOps: Merge requests generate automated
terraform planoutput. - Merging to
maintriggers automatedterraform applyvia passwordless OIDC Workload Identity Federation. Local human execution is blocked.
Hard Constraints ("Must Never")¶
- Never be consumed by another module: Deployments are leaf roots. No repository ever sources a deployment folder.
- Never introduce reusable logic: If configuration or orchestration in a deployment is needed elsewhere, it must be promoted into a Tier 2 solution.
Real-World Grinntec Platform Walkthroughs¶
The power of the 3-tier taxonomy is evident in how core platform services are implemented across Grinntec repositories.
1. Subscription Vending Walkthrough¶
When the platform team provisions a new Azure subscription, the deployment calls a single Tier 2 solution, which orchestrates five pinned Tier 1 modules:
flowchart TD
subgraph T3_DEP["Tier 3: subscription-vending deployment"]
SUB_CALL["module 'sub_gt_mkdocs_prod_westeu'"]
end
subgraph T2_SOL["Tier 2: solution-manage-azure-subscription (@ v4.1.2)"]
GLUE["• Deterministic random_id<br/>• FinOps UUID tag<br/>• Defender for Cloud azapi plans"]
end
subgraph T1_MODS["Tier 1 Resource Modules"]
M1["terraform-azurerm-subscription (@ v2.0.1)"]
M2["terraform-azurerm-storage-account (@ v3.1.0)"]
M3["terraform-azurerm-subscription-bootstrap (@ v2.4.0)"]
M4["terraform-gitlab-deployment-project (@ v1.2.0)"]
M5["terraform-azurerm-subscription-budget (@ v1.1.0)"]
end
SUB_CALL --> T2_SOL
T2_SOL --> M1
T2_SOL --> M2
T2_SOL --> M3
T2_SOL --> M4
T2_SOL --> M5
- Tier 3 Call (
subscription-vending/sub-mkdocs.tf):module "sub_gt_mkdocs_prod_westeu" { source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-azure-subscription.git?ref=v4.1.2" subscription_name = "gt-mkdocs-prod-westeu" billing_scope_id = var.billing_scope_id management_group = "mg-workloads-prod" workload_owner = "platform-team" monthly_budget = 100 } - Tier 2 Composition:
solution-manage-azure-subscriptionhandles the complex orchestration: - Creates the subscription via
terraform-azurerm-subscription(Tier 1). - Moves it to
mg-workloads-prod. - Provisions dedicated state storage
sttfstate{8-hex}viaterraform-azurerm-storage-account(Tier 1). - Vends OIDC Workload Identity, RBAC groups, and the state container via
terraform-azurerm-subscription-bootstrap(Tier 1). - Scaffolds the workload GitLab CI/CD project via
terraform-gitlab-deployment-project(Tier 1). - Sets budget alerts via
terraform-azurerm-subscription-budget(Tier 1).
2. Service Principal Vending Walkthrough¶
Platform machine identities are vended using Tier 2 solutions deployed under Lane A:
graph TD
subgraph T3_SP["Tier 3: azure-service-principals (Lane A)"]
DEP_SP["sp-azgovviz.tf"]
end
subgraph T2_SP["Tier 2: solution-manage-azure-service-principals (@ v4.3.0)"]
SOL_SP["OIDC claims + Scoped role assignment definitions"]
end
subgraph T1_SP["Tier 1: terraform-azuread-service-principal (@ v2.0.0)"]
MOD_SP["azuread_application + azuread_service_principal"]
end
DEP_SP -->|source = ...?ref=v4.3.0| SOL_SP
SOL_SP -->|source = ...?ref=v2.0.0| MOD_SP
The workload or platform deployment simply declares the identity requirement; the Tier 2 solution enforces passwordless OIDC trust policies and prevents secret generation.
When to Bypass Tier 2 (and the Anti-Patterns)¶
The 3-tier model is a strong architectural standard, not an inflexible dogma. Tier 3 deployments may occasionally call Tier 1 modules directly or write raw provider resources. However, this flexibility must be strictly governed.
flowchart TD
START{"Does a Tier 2 solution exist for this service?"}
START -- Yes --> USE_T2["Consume Tier 2 Solution<br/>(Golden Path)"]
START -- No --> Q1{"Is it a simple, standalone resource or sub-feature?"}
Q1 -- No --> CREATE_T2["Author new Tier 2 Solution in terraform-solutions/"]
Q1 -- Yes --> ALLOW_BYPASS["Bypass Tier 2:<br/>Call Tier 1 module or declare raw provider resource"]
ALLOW_BYPASS --> MONITOR{"Is this pattern being replicated in a second deployment?"}
MONITOR -- Yes: Rule of Two --> PROMOTE["Promote to Tier 2 Solution immediately!"]
MONITOR -- No --> KEEP["Retain in Tier 3 deployment"]
Legitimate Bypass Criteria¶
Direct calls to Tier 1 or raw provider resources in Tier 3 are permitted only when both criteria are met:
1. No Tier 2 solution exists yet that encapsulates the required service pattern.
2. The resource is a minor auxiliary resource (e.g. binding an apex custom domain to a Static Web App via azurerm_static_web_app_custom_domain) that requires no cross-resource policy enforcement.
The "Rule of Two" Promotion Trigger¶
If any engineer copies an un-encapsulated Tier 1 call or raw resource block into a second Tier 3 deployment, bypass permission expires. The platform team must immediately extract the common pattern into a new Tier 2 solution in terraform-solutions/ and release it with a SemVer tag.
Dangerous Anti-Patterns to Reject in Code Review¶
- The "Wild West" Bypass: Writing entire networks or compute clusters using raw provider resources in a deployment repository to bypass security lock requirements.
- Leaky Solutions: A Tier 2 solution that exposes raw child module arguments verbatim (
pass-through variables), failing to abstract complexity. - Direct Tier 1 Tag Mutation: Changing a Tier 1 module directly on the default branch without creating a tagged release, causing unpredictable builds.
CI/CD Quality Gates & Release Governance¶
Every tier is protected by automated pipelines defined in GitLab CI templates:
| Pipeline Stage | Tiers 1 & 2 (terraform-module-pipeline.yml) |
Tier 3 Lane B (terraform-deployment-pipeline.yml) |
Tier 3 Lane A (azure-tenant-root, azure-service-principals) |
|---|---|---|---|
| Syntax & Lint | terraform fmt -checktflint --recursive |
terraform fmt -checktflint |
terraform fmt -checktflint |
| Validation | terraform init -backend=falseterraform validate |
terraform initterraform validate |
terraform initterraform validate |
| Security Scanning | checkov -d . --framework terraform (JUnit report) |
checkov -d . (posted as MR note) |
checkov -d . (posted as MR note) |
| Documentation | terraform-docs checks markdown markers |
n/a | n/a |
| Plan Execution | n/a (stateless) | terraform plan on MR |
terraform plan on MR via *-ro identity |
| Apply Execution | n/a | Automated on merge to main |
Permanently disabled in CI (Local PIM apply only) |
| Artifact / Gate | Git SemVer tag (vX.Y.Z) |
Git commit merged to main |
Human PIM activation + preflight.ps1 |
Why Checkov Skips Git Ref Pins (# checkov:skip=CKV_TF_1)¶
Checkov rule CKV_TF_1 checks whether a module source references a public Terraform registry with an explicit version attribute. Because Grinntec uses private GitLab repositories, module sources use git references:
# checkov:skip=CKV_TF_1: Module is sourced from internal private git repository with explicit immutable semver tag ref
source = "git::https://gitlab.com/grinntec-cloud/terraform-modules/terraform-solutions/solution-manage-resource-group.git?ref=v2.0.0"
The State Evolution Tax & Declarative Refactoring¶
A key challenge when migrating existing deployments to higher-tier abstractions is the State Evolution Tax: restructuring modules moves resource addresses in Terraform state.
If a deployment changes from a direct Tier 1 module call:
to a Tier 2 solution: Terraform's plan engine will interpret the internal nesting shift as an instruction to destroy the existing resource group and re-create it, triggering catastrophic data loss.Zero-Downtime Refactoring with Declarative moved {} Blocks¶
Engineers must never perform manual state surgery (terraform state mv). Instead, use declarative, version-controlled moved {} blocks in the Tier 3 deployment:
# Preserves the live Azure resource group without destruction or recreation
moved {
from = module.rg_mkdocs.azurerm_resource_group.this
to = module.rg_mkdocs.module.resource_group.azurerm_resource_group.this
}
When terraform plan runs, Terraform notes the logical relocation and plans 0 destroys and 0 creates.
The Two-Step Management Lock Rule¶
Because Tier 2 solutions enforce CanNotDelete management locks alongside the resources they protect, deleting a locked resource requires two separate merge requests:
sequenceDiagram
autonumber
actor Engineer
participant MR1 as Merge Request 1 (Unlock)
participant Cloud as Azure Resources
participant MR2 as Merge Request 2 (Destroy)
Engineer->>MR1: Set enable_delete_lock = false
MR1->>Cloud: Plan & Apply: Delete lock resource ONLY
Note over Cloud: Resource remains intact, but lock is removed
Engineer->>MR2: Remove resource block or set count = 0
MR2->>Cloud: Plan & Apply: Destroy target resource safely
Attempting to delete both the lock and the resource in a single apply fails because Terraform's dependency graph may attempt resource deletion while the ARM lock is still registered.
Compliance & Standards Alignment¶
The 3-tier taxonomy maps directly to international cybersecurity and cloud architecture frameworks:
| Standard / Framework | Requirement | How the 3-Tier Taxonomy Fulfills It |
|---|---|---|
| NIST SP 800-53 Rev. 5: CM-2 | Baseline Configuration: Maintain baseline configurations of systems under formal change control. | Tier 1 modules define hardened, auditable baseline configurations (TLS 1.2, encryption, private endpoints) stored in version control. |
| NIST SP 800-53 Rev. 5: CM-3 | Configuration Change Control: Test, validate, and document changes to information system components. | Tier 1 and Tier 2 updates require SemVer releases and pipeline validation. Tier 3 deployments consume changes via explicit MRs. |
| NIST SP 800-53 Rev. 5: AC-6 | Least Privilege: Employ least privilege for accounts and automate control enforcement. | Tier 2 solutions strictly limit caller parameters. Tier 3 Lane A isolates Control Plane operations from automated CI. |
| Microsoft CAF (Ready / Operate) | Landing Zone Modularity: Structure IaC into composable, testable, independent modules. | Directly implements CAF's recommended hierarchy: Resource modules -> Composed landing zone solutions -> Deployment state stacks. |
Summary Checklist for Platform Engineers¶
When creating or modifying Terraform code in the Grinntec estate, follow this taxonomy checklist:
- Is this wrapping a single resource? Place it in
terraform-azure-modules/(Tier 1). No other modules called. - Are providers pinned pessimistically? Use
~> 4.0inversions.tf. - Does it include secure defaults? Enforce TLS 1.2+, private endpoints, and CAF naming before exposing variables.
- Is this orchestrating multiple resources for a service? Place it in
terraform-solutions/(Tier 2). - Are all module sources in Tier 2 pinned to immutable git tags? Never use floating branches.
- Is this a live running environment? Place it in
terraform-deployments/(Tier 3). - Does this deployment touch Tier 0 credentials or root state? Route to Lane A (Plan in CI, human PIM apply). All other deployments route to Lane B (GitOps automated apply).
- Does code refactoring alter state addresses? Always include declarative
moved {}blocks to prevent resource recreation.