Enforcing CIS Compliance at Design Time with Azure Verified Modules
Most Terraform compliance approaches catch violations at deploy time: Sentinel policies, OPA checks, or manual plan reviews. By then the non-compliant configuration already exists. This post describes a different approach: embedding CIS Azure Foundations Benchmark v5.0.0 controls directly into the module contract using Terraform-native precondition and check blocks. A non-compliant configuration fails at plan time, before apply is possible.
I built this as a module library on top of Azure Verified Modules (AVM), deployed through Azure DevOps pipelines. The library wraps 16 AVM resource modules into building blocks with CIS enforcement, and composes those into 17 deployable patterns.
What This Assumes
This was built for a specific context. If yours differs, the approach still applies but the implementation details change.
- Azure DevOps monorepo with Git. Works with GitHub, but the pipeline templates are Azure DevOps YAML.
- Self-hosted agents with private network access. Cloud-hosted runners work but need network connectivity to private Azure resources.
- Terraform >= 1.7 with AzureRM provider ~> 4.0. Tests use mock providers which require 1.7+.
- Azure-only. The AVM modules are Azure-specific. The compliance pattern (preconditions + checks) is provider-agnostic.
Architecture
Two layers. Building blocks wrap one AVM module each and enforce CIS. Patterns compose building blocks into deployable stacks.
Consumer code
|
v
+-----------------------------+
| Pattern |
| (avm-webapp-pattern) |
| |
| +-------+ +-------+ |
| |Key | |Web | ... |
| |Vault | |Site | |
| |(BB) | |(BB) | |
| +---+---+ +---+---+ |
+------+---------+------------+
v v
AVM Module AVM Module
(upstream) (upstream)
A consumer deploys a CIS-compliant webapp stack with one module call:
module "webapp" {
source = "git::https://dev.azure.com/org/project/_git/modules//patterns/avm-webapp-pattern?ref=avm-webapp-pattern-v1.2.0"
location = "westeurope"
resource_group_name = azurerm_resource_group.this.name
log_analytics_workspace_id = module.log_analytics.resource_id
enable_cis2_compliance = true
lock = { kind = "CanNotDelete", name = "app-lock" }
web_apps = {
frontend = { name = "webapp-frontend" }
api = { name = "webapp-api" }
}
}
CIS Enforcement
Two levels. CIS Level 1 is always active. CIS Level 2 is opt-in.
Level 1 defaults are baked into building block locals. They can only be changed via explicit override flags that default to false:
effective_config = {
encryption_at_host = !var.cis_overrides.skip_encryption_at_host # MCSB DP-4
secure_boot_enabled = !var.cis_overrides.skip_secure_boot # MCSB ES-1
vtpm_enabled = !var.cis_overrides.skip_vtpm # MCSB ES-1
}
Deploying without touching cis_overrides gives you all three. Disabling one triggers a visible warning.
Level 2 uses precondition blocks that fail the plan if a required input is missing:
resource "terraform_data" "cis2_private_endpoint_required" {
count = var.enable_cis2_compliance && !var.cis_overrides.skip_private_endpoint_check ? 1 : 0
lifecycle {
precondition {
condition = var.private_endpoint.enabled && var.private_endpoint_subnet_id != null
error_message = "MCSB NS-2 (Level 2): Private endpoint is required."
}
}
}
When an override is active, a check block generates a warning that shows up in every terraform plan:
check "cis2_private_endpoint" {
assert {
condition = !var.enable_cis2_compliance || !var.cis_overrides.skip_private_endpoint_check
error_message = "WARNING: MCSB NS-2 override active. This deviation must be approved by the security team."
}
}
Every CIS deviation is visible. A security reviewer greps for WARNING: in the plan output.
The override governance is layered. The check block makes the deviation visible at design time. The pipeline surfaces warnings in the plan output and in the PR comment. But neither blocks the deployment. The actual backstop is Azure Policy: a deny effect on a non-compliant resource blocks the deployment at the ARM layer regardless of what Terraform accepts. The check block warning and the Azure Policy deny are complementary. One is visible at design time, the other is enforced at deploy time. An approved override in the module and a policy exemption should go together. Without Azure Policy, the compliance model depends on someone reading the pipeline output.
Where This Breaks: Azure API vs. Terraform
Mock-based tests (173 test files, all command = plan) validate module logic. They do not validate Azure API constraints. Two bugs that passed every test but failed during deployment:
-
Extension publisher restrictions. A VM extension was configured with
automatic_upgrade_enabled = true. The extension publisher does not support that flag. Azure returned a 409 Conflict. The Terraform provider accepted the configuration. -
Mutually exclusive settings. Two VM configuration flags were both
true. Azure requires one to befalsewhen the other istrue. Valid Terraform, invalid Azure. The provider does not validate the relationship.
Both required code fixes after real deployments failed. terraform validate and mock providers verify HCL syntax and module logic. They do not verify Azure platform behavior.
The Wrapper Tax
HashiCorp’s module creation guide warns against thin wrappers. Every upstream AVM update means evaluating whether the wrapper still covers new features.
A concrete example: the AVM API Management module validates SKU names with a regex supporting Premium_1 through Premium_99, BasicV2, StandardV2, and more. The building block had a hardcoded contains() list with 10 values. The wrapper was silently restricting what the upstream module supports.
Wrapping is justified when:
- Hardcoding security settings that should never be overridden
- Adding preconditions that upstream does not enforce
- Composing multiple resource modules into architecture patterns
Wrapping is not justified when:
- Passing every variable through without adding logic
- Restricting upstream features without a security reason
What Maintenance Actually Looks Like
AVM publishes updates regularly. As of March 2026, the AVM Terraform index lists 136 modules (110 resource, 26 pattern). The library wraps 16 of those.
A weekly scheduled pipeline job compares pinned versions against the Terraform Registry. When an update is available, someone evaluates the changelog, updates the version pin, runs terraform init -upgrade, and verifies the tests still pass. Most updates are non-breaking (new optional variables, bug fixes). Breaking changes are rare but real: variable renames, removed outputs, changed defaults.
The actual time cost: roughly 1-2 hours per month for routine version bumps across 16 modules. A breaking change in a heavily-used building block (like Key Vault or Storage Account) can take half a day because every pattern that references it needs re-testing.
Testing
173 test files using Terraform’s native test framework with mock providers. Every CIS control is tested twice: once to confirm it passes with valid inputs, once to confirm it blocks without them.
run "cis_l2_requires_private_endpoint" {
command = plan
variables {
private_endpoint_subnet_id = null
}
expect_failures = [
terraform_data.cis2_private_endpoint_required,
]
}
expect_failures tells Terraform: this plan should fail, and specifically on this precondition. If the precondition does not fire (because someone weakened the condition or removed it), the test itself fails. This is how every CIS control stays enforced across refactors. No Azure credentials needed.
Versioning
Each module gets independent Git tags. An auto-tag script runs after merge to main, parses source references in each pattern’s main.tf to build a dependency map, and diffs against the latest tag. If a building block changes, all patterns that reference it get a new patch version automatically. Without this, a building block update could silently change behavior for multiple patterns without any of them being re-tagged.
Pipeline
Five stages in Azure DevOps:
- Format & Validate:
terraform fmt -check+terraform validateon all modules in parallel - Terraform Tests: all
.tftest.hclfiles on all modules in parallel - Auto-Tag: version tags on affected modules (main branch only)
- AVM Version Check: weekly comparison of pinned versions against the Terraform Registry
- Release Impact: PR comment showing which modules will get new versions after merge
A consumer deployment pipeline template provides Plan, Manual Approval, Apply with CIS warnings extracted from the plan output.
Alternatives
| Aspect | HCP Terraform | This approach |
|---|---|---|
| Compliance | Deploy-time (Sentinel/OPA) | Design-time (preconditions) |
| Registry | Purpose-built | Git tags + ?ref= |
| State | Built-in UI + drift detection | Self-managed (Azure Storage) |
| Cost estimation | Built-in | Not available |
| Pricing | $0.10-$0.99/resource/month | Zero per-resource cost |
| Network | Cloud runners need connectivity | Self-hosted agents inside network |
HCP Terraform provides state management, drift detection, and cost estimation that this approach does not replicate. This approach provides design-time compliance enforcement that HCP Terraform does not offer natively.
Spacelift, Env0, Scalr solve similar problems with different pricing. Terragrunt addresses code reuse but not compliance enforcement. Azure Deployment Environments targets developer self-service, not module governance.
CIS Azure Foundations Benchmark v5.0.0 (January 2026). AVM Terraform index (March 2026). HCP Terraform pricing (March 2026).