Enforcing Policies in Terraform with Sentinel (and What to Use on OpenTofu)

HashiCorp Sentinel is the policy-as-code engine built into HCP Terraform (the product formerly called Terraform Cloud) and Terraform Enterprise. It is not a standalone CLI tool you install next to Terraform — it only runs inside those two platforms, as part of a remote run. Before you build anything on it, know the licensing reality: HCP Terraform’s Free edition does include Sentinel, but capped at one policy set and five policies total. Real, CI/CD-integrated usage — policy sets linked to a version-controlled repo, policy set versions managed via API — requires the Standard or Premium edition. And if your team runs OpenTofu instead of Terraform, none of this applies to you at all: Sentinel is a proprietary HashiCorp feature with no OpenTofu equivalent, full stop.

How Sentinel Policies Work

Sentinel policies are written in HashiCorp’s own Sentinel language and run as a step in an HCP Terraform or Terraform Enterprise run, after a plan is generated and before an apply is allowed to proceed. The key detail most intros skip: policies don’t just read your .tf config files. They import the plan itself via the tfplan (or current tfplan/v2) import, which means a policy can inspect computed, resolved values — the actual AMI ID a data source resolved to, the real instance type after a variable is interpolated, whether a resource is actually being created, updated, or destroyed in this specific run.

A minimal filter against the modern tfplan/v2 import looks like this:

import "tfplan/v2" as tfplan

all_aws_instances = filter tfplan.resource_changes as _, rc {
    rc.mode is "managed" and
        rc.type is "aws_instance"
}

You can narrow further to only resources actually being created in this plan — useful because you usually don’t want to re-flag a resource that’s already compliant and simply being left alone:

import "tfplan/v2" as tfplan

new_aws_instances = filter tfplan.resource_changes as _, rc {
    rc.mode is "managed" and
        rc.type is "aws_instance" and
        rc.change.actions is ["create"]
}

Every policy resolves to a boolean in a main rule, and what happens when that rule evaluates to false depends on the policy’s enforcement level — this is the part that actually matters operationally, because it’s what decides whether a failing policy stops your pipeline:

  • advisory — the policy is allowed to fail. A warning is logged or shown, but the run proceeds. This is the default level if you don’t set one.
  • soft-mandatory — the policy must pass unless a user with override permissions explicitly overrides the failure. The override itself is logged, which is the point: it creates accountability for the specific person who chose to bypass the check.
  • hard-mandatory — the policy must pass, period. There is no override. The only way around a failing hard-mandatory policy is to remove or edit the policy itself.

The gotcha that bites teams new to this: in a VCS-driven or CI-triggered run, there usually isn’t an interactive prompt sitting there waiting for someone to click “override.” A soft-mandatory failure in an automated pipeline functionally blocks the apply exactly like a hard-mandatory one does, until someone goes into the HCP Terraform UI and overrides it by hand. If your deploy pipeline assumes “soft-mandatory just warns,” it doesn’t — it blocks until a human intervenes, same as hard-mandatory, just with an escape hatch that isn’t automatic.

A Real Policy Example

Here’s a working pattern adapted from HashiCorp’s own published Sentinel policy library, enforcing mandatory tags on EC2 instances. It walks every module in the plan, finds every aws_instance, and checks that each one’s tags attribute contains every required key:

import "tfplan"
import "strings"
import "types"

find_resources_from_plan = func(type) {
  resources = {}
  for tfplan.module_paths as path {
    for tfplan.module(path).resources[type] else {} as name, instances {
      for instances as index, r {
        if length(path) == 0 {
          address = type + "." + name + "[" + string(index) + "]"
        } else {
          address = "module." + strings.join(path, ".module.") + "." +
                    type + "." + name + "[" + string(index) + "]"
        }
        resources[address] = r
      }
    }
  }
  return resources
}

validate_attribute_contains_list = func(type, attribute, required_values) {
  validated = true
  resource_instances = find_resources_from_plan(type)

  for resource_instances as address, r {
    if r.destroy and not r.requires_new {
      continue
    }
    if r.applied[attribute] else null is not null and
       types.type_of(r.applied[attribute]) is "map" {
      for required_values as rv {
        if r.applied[attribute] not contains rv {
          print("Resource", address, "is missing required tag:", rv)
          validated = false
        }
      }
    } else {
      print("Resource", address, "has no tags attribute at all")
      validated = false
    }
  }
  return validated
}

mandatory_tags = ["Name", "owner", "environment"]

tags_validated = validate_attribute_contains_list("aws_instance", "tags", mandatory_tags)

main = rule {
  tags_validated
}

Set this as hard-mandatory and no EC2 instance missing a required tag can ever reach apply, in any workspace the policy set is attached to — not just the ones where someone remembered to add a tag validation block in the module itself. That’s the actual value proposition over native Terraform validation: Sentinel policies are organization-wide guardrails that apply across every workspace and every module, independent of what any individual module author wrote.

The same shape extends to security-focused checks. A policy blocking publicly-readable S3 buckets follows the identical pattern — filter for aws_s3_bucket resources, then assert the planned acl attribute is never "public-read" or "public-read-write", and that server-side encryption is actually configured. Because this runs against the plan, it catches the bucket even if “public” comes from a variable default or a module output that only resolves to its final value at plan time — a static scan of the raw HCL wouldn’t see that.

If You’re on OpenTofu or the Free Tier

This is the part the old version of this article never addressed, and it’s the first question anyone evaluating policy-as-code on OpenTofu actually asks: Sentinel does not run on OpenTofu. It’s a proprietary HashiCorp feature wired into HCP Terraform and Terraform Enterprise’s run pipeline — there’s no OSS build of it, and no OpenTofu fork of it exists. OpenTofu can still use HCP Terraform as a remote state backend, but the policy engine itself stays out of reach. See our OpenTofu vs Terraform comparison for the broader list of what else does and doesn’t carry over between the two.

If you’re on OpenTofu, or you’re on Terraform’s HCP Free tier and have already burned your five-policy allowance, you have three real paths:

Open Policy Agent (OPA) with Conftest

OPA, via its Rego policy language, is the closest like-for-like replacement and is what most teams migrating off Sentinel actually land on. Run tofu show -json (or terraform show -json) to get the plan as JSON, then run that JSON through conftest test against Rego policies. It’s engine-agnostic — the same policies work whether the plan came from Terraform or OpenTofu — and it runs entirely outside any HashiCorp-hosted platform, so it works from a plain CLI pipeline or any CI runner. The tradeoff: Rego has its own learning curve, and if you’re porting existing Sentinel policies, they need to be rewritten from scratch, not translated mechanically — the two languages don’t map cleanly onto each other.

Checkov or tfsec-style static scanners

For broad, pre-built security and compliance rule sets (public buckets, missing encryption, overly permissive IAM), a static scanner like Checkov gets you most of the common guardrails without writing custom policy code at all — see our comparison of Checkov, tfsec, and Terrascan for how these actually differ. The limitation versus Sentinel or OPA: these tools scan the raw .tf configuration, not the resolved plan, so they can miss issues that only become visible once variables and data sources actually resolve.

Terraform’s native preconditions, postconditions, and check blocks

If you don’t need organization-wide, cross-workspace enforcement and just want validation local to a specific resource or module, Terraform (and OpenTofu, which tracks this language feature) has had native support for a while now — no external tool required:

Preconditions and postconditions (Terraform 1.2+) live inside a resource or data source’s lifecycle block and validate before or after the plan/apply for that specific resource:

resource "aws_instance" "example" {
  instance_type = "t3.micro"
  ami           = data.aws_ami.example.id

  lifecycle {
    precondition {
      condition     = data.aws_ami.example.architecture == "x86_64"
      error_message = "The selected AMI must be for the x86_64 architecture."
    }
  }
}

Standalone check blocks (Terraform 1.5+) go further — they can validate conditions that aren’t tied to any single resource at all, and critically, a failed check produces a warning rather than blocking the entire run, which makes them useful for things you want visibility on without hard-stopping every apply:

check "health_check" {
  data "http" "terraform_io" {
    url = "https://www.terraform.io"
  }

  assert {
    condition     = data.http.terraform_io.status_code == 200
    error_message = "${data.http.terraform_io.url} returned an unhealthy status code"
  }
}

The real tradeoff versus Sentinel: these live in the module code itself, so they only apply where someone remembered to write them, and they can’t be enforced centrally across every workspace in an org the way a Sentinel policy set attached at the organization level can. They’re a genuinely good option for module-author-level validation; they’re not a replacement for org-wide governance.

Frequently Asked Questions

Q: Does Sentinel work with plain open-source Terraform (just the CLI, no HCP Terraform account)?
A: No. Sentinel only runs as part of a remote run inside HCP Terraform or Terraform Enterprise. There’s no way to run Sentinel policies against a purely local terraform plan/apply workflow.

Q: Is Sentinel free?
A: Partially. HCP Terraform’s Free edition includes Sentinel, but limited to one policy set and five policies total. Linking policy sets to version control or managing them via the API requires the Standard or Premium edition.

Q: Does OpenTofu have a Sentinel equivalent?
A: No. There is no OpenTofu-native policy-as-code engine equivalent to Sentinel. OpenTofu users typically use Open Policy Agent with Conftest, a static scanner like Checkov, or Terraform’s native precondition/postcondition/check blocks (all of which OpenTofu also supports, since they’re a language feature, not a HashiCorp-platform feature).

Q: What’s the difference between Sentinel’s soft-mandatory and hard-mandatory levels?
A: Soft-mandatory can be overridden by a user with the right permissions (and the override is logged); hard-mandatory cannot be overridden at all — the only way past it is to edit or remove the policy itself.

Q: Do Sentinel policies see my actual resource values, or just my Terraform code?
A: They see the plan — the tfplan/tfplan/v2 import exposes resolved, computed values from the specific run, not just the static .tf source. That’s what lets a policy catch things that only become visible after variables and data sources resolve.

Quick Summary

  • Sentinel only runs inside HCP Terraform or Terraform Enterprise — never plain Terraform CLI, and never OpenTofu
  • HCP Terraform’s Free edition includes Sentinel but caps it at one policy set, five policies; real CI/CD usage needs Standard or Premium
  • Sentinel policies evaluate the plan (resolved values), not just raw config — that’s the whole point of running it post-plan
  • Enforcement levels: advisory (warns only), soft-mandatory (blocks unless an authorized user overrides), hard-mandatory (blocks, no override possible)
  • In automated/CI-triggered runs, soft-mandatory effectively blocks just like hard-mandatory, since there’s no interactive override prompt — someone has to go into the UI by hand
  • OpenTofu and free-tier Terraform users have real alternatives: OPA/Conftest (closest Sentinel equivalent), Checkov-style static scanners, or native precondition/postcondition (1.2+) and check blocks (1.5+) for module-local validation

Related guides