TL;DR: Evaluate policy against the Terraform plan (not just the HCL), because only the plan knows the resolved values of variables, modules, and data sources. Layer fast static scanning in pull requests with plan-time policy in the pipeline, roll every new rule out as advisory before making it blocking, and design a first-class exception path. Most teams that feel slowed down by policy as code are suffering from bad rollout and missing exceptions, not from the engine they picked.

Manual review of infrastructure changes does not scale past a handful of teams. The reviewer becomes a queue, reviews become rubber stamps, and the same misconfigurations — public buckets, unencrypted volumes, 0.0.0.0/0 ingress, oversized instances — keep landing. Policy as code turns those review comments into executable rules that run on every change. The engineering challenge is not writing the first rule; it is building a system that hundreds of engineers will not route around.

This article covers the architecture: where policies should run, what they should evaluate, how OPA and Sentinel differ in practice, and the rollout and exception mechanics that decide whether the whole effort survives.

Where guardrails can run

There are four realistic enforcement points in a Terraform workflow, and they catch different things.

  1. Pre-commit / IDE — static scanners on raw HCL. Fast, no credentials needed, great developer feedback. Blind to anything resolved at plan time.
  2. Pull request CI — static scanning plus, ideally, a speculative terraform plan evaluated against policy. This is the highest-leverage point: the author is still in context and the fix is cheap.
  3. Apply pipeline / TACOS — plan-time policy evaluated immediately before apply, in Terraform Cloud/Enterprise, Spacelift, Scalr, env0, or a self-built pipeline. This is the actual gate.
  4. Runtime / cloud-native — AWS SCPs, Azure Policy, GCP Organization Policy, Kubernetes admission control. The backstop for anything that bypasses Terraform entirely (console clicks, other tools).

A mature setup uses at least three of these. The mistake is treating any single layer as sufficient. Static scanning alone misses variable-driven misconfiguration; plan-time policy alone gives feedback too late; cloud-native controls alone produce opaque AccessDenied errors mid-apply with no explanation of which rule fired.

Why the plan matters more than the HCL

Static scanners such as Checkov and Trivy (which absorbed tfsec's checks after tfsec was deprecated) read HCL directly. That is fast and credential-free, but HCL frequently does not contain the values that matter. A module call like this is opaque to a static reader:

hcl
module "data_bucket" {
  source      = "git::https://git.example.com/modules/s3.git?ref=v4.2.0"
  name        = "${var.env}-analytics"
  public_read = var.env == "sandbox"
}

Whether public_read resolves to true depends on a variable value. Whether the remote module actually sets block_public_acls depends on code the scanner may not have fetched. A terraform plan resolves all of that: modules, variables, locals, data sources, and computed values where known. Running terraform show -json plan.tfplan gives a machine-readable document containing resource_changes, each with before, after, and actions fields. That is the input policy engines should evaluate for anything security-relevant (Spacelift, OneUptime).

Plan-time evaluation also unlocks change-aware policy. Because the plan includes actions, you can write rules that only fire on create or update, that forbid delete on stateful resources, or that compare before and after to block, say, reducing a backup retention period. Static HCL scanning cannot express any of that.

The cost: a plan requires cloud credentials and state access, and takes seconds to minutes. That is why the layered approach — static in pre-commit, plan-time in CI and at apply — is the norm.

OPA vs Sentinel: what actually differs

Open Policy Agent (OPA) with Conftest

OPA is a general-purpose policy engine; policies are written in Rego and evaluate arbitrary JSON. For Terraform, the common pattern is Conftest running Rego against plan JSON in CI, or a TACOS that embeds OPA natively.

rego
package terraform.s3

import rego.v1

deny contains msg if {
  some rc in input.resource_changes
  rc.type == "aws_s3_bucket_public_access_block"
  rc.change.actions[_] in {"create", "update"}
  not rc.change.after.block_public_acls
  msg := sprintf("%s: block_public_acls must be true", [rc.address])
}

deny contains msg if {
  some rc in input.resource_changes
  rc.type == "aws_db_instance"
  "delete" in rc.change.actions
  not exempt(rc.address)
  msg := sprintf("%s: deleting a database requires an approved exception", [rc.address])
}

exempt(addr) if addr in data.exceptions.allowed_deletes
bash
terraform plan -out=plan.tfplan
terraform show -json plan.tfplan > plan.json
conftest test plan.json --policy policy/ --data exceptions/

Strengths: the same engine and language can govern Kubernetes admission, API authorization, and CI checks, so platform teams build one skill set. Rego has a built-in unit testing framework (opa test). It is open source and runs anywhere.

Weaknesses: Rego's declarative, set-based semantics have a real learning curve, and the raw plan JSON structure is verbose — expect to write helper functions for walking modules and handling unknown values.

HashiCorp Sentinel

Sentinel is HashiCorp's embedded policy language, available in HCP Terraform (Terraform Cloud) and Terraform Enterprise. Its tfplan/v2, tfconfig/v2, tfstate/v2, and tfrun imports expose Terraform data structures natively, so you do not parse plan JSON yourself.

sentinel
import "tfplan/v2" as tfplan

rds = filter tfplan.resource_changes as _, rc {
  rc.type is "aws_db_instance" and
  rc.mode is "managed" and
  (rc.change.actions contains "create" or rc.change.actions contains "update")
}

encrypted = rule {
  all rds as _, db { db.change.after.storage_encrypted is true }
}

main = rule { encrypted }

Strengths: first-class Terraform data model, and — the feature that matters most operationally — built-in enforcement levels: advisory (warn, continue), soft-mandatory (block unless an authorized user overrides), and hard-mandatory (block, no override) (Scalr). Overrides are logged, which gives you an audit trail for free.

Weaknesses: tied to HashiCorp's commercial platform; skills do not transfer to Kubernetes or other domains. HCP Terraform also supports OPA policy sets, so the choice is increasingly about language preference and portability rather than platform capability.

Comparison

DimensionOPA / ConftestSentinel
Where it runsAnywhere (CI, TACOS, K8s, services)HCP Terraform / Terraform Enterprise
InputAny JSON (plan JSON for Terraform)Native Terraform imports
Enforcement levelsBuild your own (or TACOS provides)Advisory / soft / hard built in
Override audit trailBuild your ownBuilt in
Testingopa test, Conftest verifysentinel test
Portability beyond TerraformHighLow
Learning curveSteeper (Rego)Moderate

If you already run HCP Terraform and only need to govern Terraform, Sentinel's enforcement levels and override logging save real engineering time. If you run a mix of Terraform, Kubernetes, and other declarative systems, standardising on OPA pays off in shared tooling and skills. In both cases, a scanner like Checkov or Trivy in pre-commit is complementary, not a replacement.

Designing the guardrail system so teams do not route around it

1. Enforcement levels are the rollout mechanism

The same rule behaves completely differently as advisory versus hard-mandatory, and most rollout pain traces back to picking the wrong level on day one (Coding Protocols). A lifecycle that works:

  1. Advisory for two to four weeks. Log every violation. Measure how often the rule fires and on which workspaces.
  2. Fix or exempt the existing violations. Open tickets, or add explicit exceptions for legitimate cases.
  3. Soft-mandatory. Blocks by default, but a designated approver can override with a reason. Watch the override rate.
  4. Hard-mandatory only for rules where an override is never legitimate — public access to regulated data stores, disabled audit logging, wildcard IAM on production accounts.

If you are on plain OPA without a TACOS, implement levels yourself: tag each rule with metadata (level: advisory|soft|hard), have policies emit warn and deny sets separately, and fail the pipeline only on deny from soft/hard rules without a matching exception.

2. Build exceptions as data, not code edits

Engineers bypass controls when the legitimate path is slower than the illegitimate one. The legitimate path must exist and be fast. Store exceptions as version-controlled data — resource address or tag, rule ID, reason, owner, expiry date — and have policies consult that data. Require expiry dates; an exception without an expiry is a permanent hole. Review exceptions in the same pull-request flow as code.

yaml
# exceptions/allowed_deletes.yaml
- rule: rds-no-delete
  address: module.legacy_reporting.aws_db_instance.main
  reason: "Decommissioning per CHG-4411"
  owner: data-platform
  expires: 2026-11-30

3. Make every failure message actionable

deny: policy violated guarantees a Slack message to the platform team. A good failure message names the resource address, the rule ID, why the rule exists, how to fix it, and how to request an exception. The extra few minutes spent on messages pays back every time the rule fires.

4. Test policies like code

Every rule needs unit tests with a passing and failing fixture — small plan JSON snippets checked into the repo. Run opa test or sentinel test in CI for the policy repository itself. A broken policy that denies everything will halt every team at once; a broken policy that allows everything will go unnoticed. Both are worse than no policy.

5. Version and distribute policies centrally

Keep policies in a dedicated repository, publish versioned bundles (OPA bundles, or Sentinel policy sets pinned to a VCS ref), and roll new versions out progressively — sandbox workspaces first, then non-production, then production. This is the same promotion discipline you would apply to application code.

6. Keep the fast feedback loop fast

Plan-time policy evaluation itself is usually quick; the plan is the slow part. Do not add a second plan just for policy — evaluate the plan your pipeline already produces. Keep pre-commit scanning to checks that run in seconds. Cache provider plugins and modules in CI.

Failure modes to design against

  • Unknown values. Some attributes are (known after apply) in the plan. A rule that treats unknown as a violation will block legitimate changes; a rule that treats it as compliant will miss real issues. Decide per rule and document it; for security-critical attributes, check after_unknown and require the value be set explicitly in configuration.
  • Module-nested resources. Resources inside modules appear with addresses like module.a.module.b.aws_s3_bucket.x. Match on type, not address prefix, unless you intend to scope a rule.
  • Policy drift between layers. If pre-commit scanning and plan-time policy disagree, engineers stop trusting both. Map static-scanner check IDs to plan-time rules and retire duplicates.
  • Out-of-band changes. Terraform policy cannot see console edits. Pair it with cloud-native guardrails and drift detection.
  • Policy as a bottleneck team. If only one person can write Rego, the policy backlog becomes the new review queue. Provide a template, a test harness, and a contribution guide so application teams can propose rules.

A reference pipeline

  1. Developer commits → pre-commit runs terraform fmt, validate, and a static scanner on changed directories.
  2. Pull request opened → CI runs terraform plan, converts to JSON, evaluates the central policy bundle, posts violations as a PR comment grouped by level.
  3. Merge → apply pipeline re-plans against current state, re-evaluates policy (state may have moved since the PR), enforces soft/hard rules, records overrides.
  4. Apply → cloud-native guardrails (SCPs, Organization Policy) act as the backstop.
  5. Nightly → drift detection plans every workspace and evaluates policy against current state to surface out-of-band changes.

Whichever infrastructure-as-code stack you use, this shape carries over. It fits naturally into existing DevOps and CI/CD pipelines, and it gives compliance and risk teams something they rarely get from manual review: evidence that every change was evaluated against the same rules.

Measuring whether the guardrails are working

A policy programme needs its own metrics, or it drifts into either irrelevance or obstruction. Four numbers are worth tracking per rule and per team:

  • Violation rate — how often a rule fires per plan evaluated. A rule that never fires may be redundant with a module default; a rule that fires on a large share of plans probably encodes a standard teams do not understand or cannot meet.
  • Override and exception rate — for soft-mandatory rules, the share of violations resolved by override rather than fix. A rising rate is the leading indicator that engineers are routing around the rule. Investigate before it becomes normal.
  • Time to remediate — elapsed time from a violation in a pull request to a passing plan. If fixes routinely take hours, the failure messages or the module library need work.
  • Exceptions past expiry — any non-zero value means the exception review process is not running.

Emit these as structured events from the pipeline (rule ID, level, workspace, outcome) into whatever observability stack you already use, and review them monthly with the teams most affected. The goal is a small set of rules that fire rarely, are fixed quickly, and are almost never overridden. When a rule consistently fails that test, change the rule, the module defaults, or the documentation — not the enforcement level.

A useful companion practice is to push fixes upstream into shared modules. If the encryption rule fires constantly, the real fix is a storage module that encrypts by default, so compliance becomes the path of least resistance and the policy becomes a safety net rather than a daily obstacle.


Syslabs' engineering team builds guardrail pipelines of this kind as part of its cloud infrastructure work.

Sources: Spacelift — Terraform policy as code · Scalr — OPA vs Sentinel · Coding Protocols — OPA/Conftest vs Sentinel vs Checkov · OneUptime — Scanning Terraform plans · env0 — Checkov vs Trivy