Policy as Code writes compliance rules in code and evaluates them automatically — the same move Infrastructure as Code made for infrastructure. The rules run against a plan, so a violation is caught before anything is created rather than found in an audit months later.
Topic 1: The Three Kinds of Compliance
- Security — network segmentation, encryption at rest and in transit, access control.
- Regulatory — data-protection, healthcare, financial and payment-card regimes, each with its own evidentiary requirements.
- Operational — internal policy on tagging, permitted resource types, permitted regions, cost ceilings.
All three reduce to the same mechanical question: does this plan satisfy a set of rules? That question is answerable automatically.
Topic 2: The Plan Is the Input
Policy engines do not read your HCL. They read the JSON plan, which is the fully-resolved set of changes after variables, locals, functions and modules have been evaluated.
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
This matters: a rule written against HCL can be defeated by indirection — a variable, a module, a computed value. A rule written against the plan sees the actual resource attributes about to be created, whatever route they took to get there.
{
"resource_changes": [{
"address": "aws_s3_bucket.artifacts",
"type": "aws_s3_bucket",
"change": {
"actions": ["create"],
"after": { "bucket": "org-artifacts", "acl": "private" }
}
}]
}
Topic 3: An Embedded Policy Framework
The commercial Terraform offerings ship a policy language that evaluates before any change applies:
import "tfplan/v2" as tfplan
main = rule {
all tfplan.resource_changes as _, rc {
rc.change.after.instance_type in ["t3.micro", "t3.small"]
}
}
Policies are attached to workspaces and enforced at run time, with severity levels — advisory (warn), soft-mandatory (overridable by an authorised person), hard-mandatory (blocks unconditionally).
Topic 4: A General-Purpose Policy Engine
An open-source alternative, usable with many tools, typically run in CI:
package terraform.s3
deny[msg] {
resource := input.resource_changes[_]
resource.type == "aws_s3_bucket"
resource.change.after.server_side_encryption_configuration == null
msg := sprintf("Bucket %s must have encryption enabled", [resource.address])
}
deny[msg] {
resource := input.resource_changes[_]
required := {"Environment", "Owner", "ManagedBy"}
provided := {k | resource.change.after.tags[k]}
missing := required - provided
count(missing) > 0
msg := sprintf("%s missing required tags: %v", [resource.address, missing])
}
- name: Policy check
run: |
terraform show -json plan.tfplan > plan.json
opa eval --data policy.rego --input plan.json --fail-defined "data.terraform.s3.deny[_]"
--fail-defined makes the step exit non-zero when any deny rule produces a message, which is what fails the build.
Topic 5: Policies Worth Writing First
Ranked by how much trouble they prevent per line:
| Policy | Prevents |
|---|---|
| Encryption required on storage and databases | The most common audit finding |
| No publicly-readable object storage | The most common breach headline |
| Required tags present | Untraceable cost and unattributable resources |
| Instance types from an approved list | Runaway spend |
| Regions from an approved list | Data-residency violations |
No security group open to 0.0.0.0/0 on admin ports | The most common intrusion path |
prevent_destroy on stateful resource types | Accidental data loss |
Start with tags and encryption. They are cheap to write, unambiguous to evaluate, and unlock the reporting everything else depends on.
Topic 6: The Compliance Challenges Policy Cannot Solve Alone
Configuration drift. Policy evaluates plans; it says nothing about changes made outside Terraform entirely. Pair it with scheduled terraform plan -refresh-only and alert on non-empty output.
Secret sprawl. Covered properly in the security lesson — secret managers, least-privilege IAM, encrypted state.
Regulatory reporting. Auditors want evidence over time, not a passing build today. That means immutable audit logs of every API call Terraform made:
resource "aws_cloudtrail" "audit" {
name = "terraform-audit"
s3_bucket_name = aws_s3_bucket.audit_logs.id
include_global_service_events = true
is_multi_region_trail = true
enable_logging = true
}
The equivalent elsewhere is a diagnostic setting capturing administrative actions with a retention policy — commonly 365 days to cover an annual audit cycle.
Combined with version-controlled configuration history and stored policy-evaluation results, that gives you the three things an auditor actually asks for: what the intended state was, what actually happened, and what checks it passed.
Topic 7: Continuous Compliance
The goal is checks in every pipeline run rather than an annual scramble:
- Standardise policies across teams in one repository, versioned like any other code.
- Review and update them as requirements evolve — a policy set nobody has touched in two years is describing a system that no longer exists.
- Audit regularly so unauthorised change is detected early rather than discovered later.
- Make the failure message actionable. “Policy violation” teaches nobody; “Bucket X must have encryption enabled — add a
server_side_encryption_configurationblock” fixes the problem without a support request.
Try it yourself: Write the required-tags policy and run it against a plan for your existing infrastructure. Almost every estate fails this on the first run, and the list of failures is a useful inventory of what nobody owns.
Common mistake: Writing policies against the HCL source instead of the JSON plan. Source-level rules miss anything expressed through a variable, a module or a function — which is most things in a mature configuration, and precisely where the violations hide.