Terraform Policy went generally available in 1.17.0-beta1 on 9 September 2026. The -policies flag on init, plan and apply no longer requires -allow-experimental-features, which means policy as code is now something the Terraform CLI does rather than something you buy alongside it. HashiCorp announced Sentinel at HashiConf in 2017, so that shift took nine years.
The feature is genuinely good. What teams will get wrong on the first afternoon is which part of a policy decides whether it can block: the attribute your condition reads, rather than the enforcement level you set.
Three stages, and you do not pick one
Terraform Policy evaluates at three separate moments. At setup time it runs provider and module policies against arguments like source and version, before anything is downloaded. At plan time it evaluates against the proposed changes. At apply time it evaluates against values the provider computes during the apply itself: ARNs, resource IDs, assigned ports, generated names, everything a plan renders as (known after apply).
That third stage is the reason the feature is interesting. The common shape today is Conftest or OPA reading a terraform plan -json artifact in CI, and that pipeline is structurally blind to every value the provider computes. A whole class of real rules lives there.
It is also the reason the feature is dangerous. Terraform derives the stage from the inputs of your enforce block. You do not declare it and you cannot override it, and nothing in the policy file tells you which one you got.

The same rule, twice
Policies are HCL, in files ending .policy.hcl. A file containing resource or provider policies needs a policy block with a nested required_providers, which is its own dependency declaration, separate from your root module’s.
Here is a rule that blocks:
# policies/storage.policy.hcl
policy {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
}
resource_policy "aws_ebs_volume" "encrypted" {
operations = ["create", "update"]
enforcement_level = "mandatory"
enforce {
condition = attrs.encrypted == true
error_message = "EBS volumes must be encrypted."
}
enforce {
condition = attrs.type != "standard"
error_message = "Magnetic EBS volumes are not permitted."
}
}
encrypted is declared in your configuration. Terraform can evaluate the condition while building the plan, so the policy runs at plan time and nothing is created. You run it like this:
terraform init -policies=./policies
terraform plan -policies=./policies -out=tfplan
terraform apply -policies=./policies tfplan
Now the rule everyone actually wants, because encryption with an arbitrary key is not the requirement. The requirement is encryption with the approved key:
locals {
approved_key = core::getdatasource("aws_kms_key", {
key_id = "alias/approved-ebs-key"
})
}
resource_policy "aws_ebs_volume" "approved_key" {
enforcement_level = "mandatory"
enforce {
condition = attrs.kms_key_id == local.approved_key.arn
error_message = "EBS volumes must use the approved KMS key."
}
}
Same resource, same enforcement level, same review, and completely different behaviour. If the calling module passes kms_key_id explicitly, the value is in the configuration and this still blocks at plan. If the module leaves the key to the provider default, the resolved ARN is known only after apply, so the policy evaluates once the volume exists. Terraform reports the violation, the apply has already happened, and somebody has to remediate and redeploy by hand.
The stage was decided by a calling module, in a different repository, possibly by a different team.
A policy that reports after the resource exists gives you an alarm where you wanted a control.
There is a second trap inside the workaround. core::getdatasource() reads at plan time only while its own arguments contain no unknown values. Feed it something computed and the data source itself defers to apply, which silently relocates every policy that depends on it. The key_id above is a static string for exactly that reason.
Where to put rules that must block
The reliable lever is the setup stage, because source and version are always known before anything downloads. Provider and module policies are the part of this feature with no ambiguity about when they run:
provider_policy "aws" "official_only" {
enforcement_level = "mandatory"
enforce {
condition = meta.source == "hashicorp/aws"
error_message = "Only the official AWS provider is allowed."
}
}
module_policy "pinned" "explicit_version" {
enforcement_level = "mandatory"
enforce {
condition = meta.version != null
error_message = "Modules must be called with an explicit version constraint."
}
}
Deletion rules are the other safe case, because prior state is always known before the plan is built. They are also the ones most often written wrong, since operations has to be set explicitly and the attributes come from prior_attrs rather than attrs:
resource_policy "aws_db_instance" "protect_prod" {
operations = ["delete"]
enforcement_level = "mandatory_overridable"
filter = prior_attrs.tags.Environment == "prod"
enforce {
condition = prior_attrs.tags.ApprovedForDeletion == "true"
error_message = "Production databases need an approved deletion tag."
}
}
For everything else, run two gates and be honest with yourself about which is which. Keep the existing Rego corpus as the blocking decision on config-declared shape, and treat Terraform Policy’s apply-time stage as an audit that fails the job loudly:
- name: Init (setup-time policies run here)
run: terraform init -policies=./policies
- name: Plan (plan-time policies block here)
run: terraform plan -policies=./policies -out=tfplan
- name: Rego gate
run: |
terraform show -json tfplan > plan.json
conftest test plan.json
- name: Apply (apply-time policies report AFTER changes)
run: terraform apply -policies=./policies tfplan
A non-zero exit on that last step means the change landed and is non-compliant. Write the runbook for it before you need it.
Three enforcement points, compared
| Plan-time policy | Apply-time policy | Conftest on plan -json | |
|---|---|---|---|
| Can see | Config-declared attributes | Provider-computed values: ARNs, IDs, ports | Config-declared attributes |
| Blocks the change | Yes | No | Yes |
| A failure means | Nothing was created | The resource exists and is non-compliant | Nothing was created |
| Needs apply credentials | No | Yes | No |
| Works on OpenTofu | No | No | Yes |
Where we have seen this question before
Oxeye came to us for monitoring and logging support billed per hour, and over two years that grew into a full-time partnership across their whole AWS and EKS estate. Early on, everything ran in two flat AWS accounts.
The instinct in that position is to write the rules down. The real problem was that there was nowhere to put them: in two shared accounts a guardrail is a convention, held up by whoever reviews the pull request and remembers. We moved the estate onto an AWS Organization with SSO and RBAC, and the account boundary started doing the enforcing instead of the wiki page.
Terraform Policy is the same question one layer down. Wherever nothing refuses, the rule is documentation with strong opinions.
The honest trade-offs
Terragrunt run --all is where this hurts most. A mandatory apply-time violation in unit seven of thirty fails that unit after its resources exist and are recorded in state. You end up with six units applied, one applied and flagged, and twenty-three never attempted. No single re-run reconciles that, because the offending resources already exist, so the documented remedy of resolving and redeploying becomes a hand-driven apply per affected unit. Test that failure against a scratch estate before you trust it in CI.
-policies on init can take the whole pipeline down. Setup-time policies evaluate before providers and modules download, so an over-broad module source policy fails init, the step every other job depends on, including plans on unrelated pull requests. It fails before the module that would explain why has even been fetched.
mandatory_overridable has no override mechanism outside HCP Terraform. The override workflow needs identity and permissions, which is why it is documented on the HCP side rather than the CLI side. In a pure CLI pipeline the level degrades into either a hard block or an ungoverned bypass that any job can set. Teams pick it expecting break-glass and get neither break nor glass.
- CI retry logic will do the wrong thing. Most retry wrappers assume a failed step changed nothing. An apply that made changes and then failed policy, retried, re-applies and re-reports; with an auto-rollback wrapper it now destroys a resource that policy merely flagged.
operationsdefaults to["create", "update"]. Deletion is unguarded unless you say otherwise, and on deleteattrsis nullified, so a protection policy has to setoperations = ["delete"]and readprior_attrs. Easy to get silently wrong.- Enforcement is per invocation.
-policiesis a flag, not a server. Anyone runningterraform applyfrom a laptop without it bypasses the corpus entirely. The real control is the IAM change that makes CI the only principal allowed to apply. - Apply-time evaluation holds the state lock longer. Policies run after changes but inside the operation. On a shared estate with a CI queue, everyone pays that.
- OpenTofu has no equivalent today. For estates that moved, this decision is already made.
One more, for anyone under audit: a rule that reports after creation means the resource was non-compliant for the duration of the apply. For a regime that counts exposure windows, detection after the fact is not a preventive control, and an assessor will classify it that way. Those rules belong at plan time even when the plan-time version is cruder.
What to do this month
- Put provider and module policies in first. They are unambiguous, they run at setup, and they retire a category of review comments immediately.
- For every candidate resource policy, ask which attributes the condition reads and whether your modules declare them. That answer, not the enforcement level, tells you whether you have a gate.
- Keep the Rego you already have. This adds a second enforcement point rather than replacing the first.
- Fix who can apply before you write policy number ten.
Facing this on your stack? Naviteq’s senior platform team does this for SaaS, FinTech, and Enterprise teams across the US, EU, and Israel. Let’s talk.
Naviteq. DevOps, FinOps and AI-driven cloud automation, delivered at scale.