A module monorepo and the delivery machinery around it. Most Terraform module collections stop at "here are some modules" — this repository also answers the questions that actually decide whether infrastructure delivery is safe and repeatable:
- Who deploys? An OIDC-federated service principal you create once with the optional
bootstrap/— no client secrets exist anywhere in the system. - Where does state live? In your subscription, in hardened storage (versioning + soft delete), one state file per deployment per environment.
- What stops a bad deploy? Layered gates: per-module CI (fmt/validate/tflint/trivy), a readiness checklist (access level, applied policies), an Azure Policy pre-flight that blocks Deny conflicts before apply with a human-readable report, and GitHub environment approvals in front of every apply.
- What is anything called? One machine-readable naming scheme (
modules/naming/naming.json, per Microsoft CAF) read by both Terraform and the workflows — the two can never disagree.
flowchart TB
subgraph setup["One-time setup (optional bootstrap/)"]
B[terraform apply bootstrap/] --> SP["App registration + OIDC<br/>federated to your fork"]
B --> SA["State storage account<br/>versioning + soft delete"]
B --> RA["Contributor grant<br/>on your subscription"]
PF["preflight.sh / .ps1<br/>✅ access level ℹ️ policy notice"]
end
subgraph repo["Your fork (trunk-based: main + short-lived branches)"]
M["modules/*<br/>19 resource modules, tagged"]
N["naming.json<br/>CAF scheme as data"]
D["deployments/*<br/>roots + env tfvars"]
end
subgraph pipeline["Deploy workflow (dispatch: deployment / environment / action)"]
G["Quality gates<br/>fmt · validate · trivy"] --> RC["Readiness checklist<br/>app role + policy count"]
RC --> P["terraform plan<br/>(state in your subscription)"]
P --> PP{"Policy pre-flight<br/>plan types × Deny assignments"}
PP -- conflict --> X["❌ Readable conflict table<br/>policy · assignment · fix options"]
PP -- clean --> A{{"Environment approval<br/>(required reviewers)"}}
A --> AP["terraform apply"]
end
subgraph azure["Your Azure subscription"]
ST[("tfstate<br/><deployment>.<env>.tfstate")]
RES["Resources<br/>CAF-named, secure defaults"]
end
SP -. "OIDC token per run" .-> pipeline
D --> G
N --> M
M --> P
AP --> RES
P <--> ST
AP <--> ST
- Modules own resources, roots own wiring, tfvars own environments. Modules never create resource groups, VNets or subnets — callers pass IDs, which makes every module equally usable greenfield (new resources) and brownfield (
datasources against existing estate). - Secure by default, explicit to weaken. Public network access off, deny-by-default network rules, TLS 1.2 floor, RBAC over access policies, managed identities over credentials. A dev convenience (public vault, purgeable on destroy) is a visible line in
env/dev.tfvars, never a hidden default. - Releases are tags, not branches.
<module>/vMAJOR.MINOR.PATCHon trunk; consumers pinref=and upgrade deliberately. Branching model: docs/branching.md (trunk-based — nodevelop, no environment branches). - Policy awareness is part of delivery, not an afterthought. The pre-flight maps a plan's resource types to ARM types and scans every assignment in scope (initiatives expanded, parameterized effects resolved). What it can't prove statically, the apply-time parser catches — either way you get a table naming the policy, the assignment and your options, not a JSON wall.
- Everything client-specific is configuration. Three secrets, four repo variables, tfvars. Fork the repo and nothing in the code needs to change.
az login
bootstrap/preflight.sh # see your checkmarks
cd bootstrap && terraform init && terraform apply \
-var github_repository="<you>/<fork>" \
-var state_storage_account_name="<unique>" # optional, once
# paste the github_setup_commands output, create dev/prod environments
scripts/tf.sh key_vault dev plan # or Actions -> Deploy moduleFull walkthrough: docs/client-onboarding.md.
module "key_vault" {
source = "git::https://github.com/KarlesP/iac-tf-azure.git//modules/key_vault?ref=key_vault/v1.0.0"
name = module.naming.names.key_vault
location = "westeurope"
resource_group_name = azurerm_resource_group.this.name
tenant_id = data.azurerm_client_config.current.tenant_id
tags = local.tags
}| Module | Description |
|---|---|
naming |
CAF-compliant name generator driven by naming.json (no resources) |
key_vault |
Key Vault with RBAC, network ACLs, keys, access policies |
storage_account |
Hardened storage account (deny-by-default networking, AAD auth) |
table_storage |
Tables on an existing storage account |
sftp |
SFTP-enabled storage with optional Log Analytics diagnostics |
app_service |
Windows App Service + plan + Application Insights |
app_gateway |
Application Gateway, fully parametrized listeners/pools/rules |
function_app |
Function App (Windows or Linux) + plan + insights |
static_web_app |
Static Web App + optional custom domain |
sql_server |
Azure SQL Server + database + firewall rules |
sql_managed_instance |
SQL MI into an existing delegated subnet |
cosmosdb |
Cosmos DB (NoSQL) + optional private endpoint |
linux_vm / windows_vm |
Single VMs on an existing subnet (SSH-first on Linux) |
vm_extensions |
CustomScript extensions for existing VMs |
container_registry |
ACR with optional firewall (Premium) |
container_app |
Container Apps environment + app with CPU autoscaling |
container_instance |
Container group, private (delegated subnet) or public |
avd |
AVD host pool + app group + session-host VMSS |
Authoring standard (file layout, naming, secure defaults): docs/module-standards.md.
| Path | What | Gate |
|---|---|---|
| Actions → Deploy module | plan/apply/destroy a deployment root | quality gates → readiness checklist → policy pre-flight → environment approval |
| Actions → E2E deploy with policy pre-flight | full deploy + optional teardown, per-run CAF names | same pre-flight; proves the pipeline end to end |
| Actions → Retiring services report | weekly scan for VM sizes Microsoft is retiring, with migration options (Rust, scripts/huginn) |
— |
scripts/tf.sh / tf.ps1 |
the same roots locally; local state unless TF_STATE_* set |
terraform's own confirmation |
Per-module semver tags: <module>/vMAJOR.MINOR.PATCH. A breaking variable/output change bumps MAJOR — consumers are pinned, so trunk can move as fast as it likes.
Short-lived branch → PR → squash to main. CI validates only the modules your PR touches. Start with docs/branching.md and docs/module-standards.md.