Sentinel Policy as Code: Governance for Terraform at Scale

Terraform gives teams the power to provision infrastructure with a few lines of code. That power, left unchecked, leads to expensive mistakes.
Developers accidentally deploy oversized instances. Someone creates a public S3 bucket. Mandatory tags get skipped. These problems surface later as cost overruns, security incidents, or compliance failures.
Sentinel is HashiCorp's answer to infrastructure governance. It intercepts Terraform runs before they apply, evaluates your policies, and blocks non-compliant changes. Think of it as automated code review for infrastructure - except it never gets tired, never misses edge cases, and runs on every single change.

This guide walks you through writing effective Sentinel policies for real-world governance scenarios.
_____
How Sentinel Works
The flow is straightforward:

Sentinel evaluates policies against the Terraform plan, state, and run data. Policies can have different enforcement levels:
- Hard-mandatory - Block apply if failed. No override possible. Use this for security non-negotiables.
- Soft-mandatory - Block apply, but authorized users can override with justification. Good for policies with legitimate exceptions.
- Advisory - Warn but don't block. Perfect for rollout periods when you're introducing new policies.
______
Sentinel Language Basics
Sentinel has its own language - simple, purpose-built for policy evaluation. It won't take long to pick up if you're comfortable with any programming language.
Core Concepts
1# Imports provide access to Terraform data
2import "tfplan/v2" as tfplan
3import "tfstate/v2" as tfstate
4import "tfconfig/v2" as tfconfig
5import "tfrun" as tfrun
6
7# Main rule - must evaluate to true for policy to pass
8main = rule {
9 all_instances_have_tags and
10 no_public_buckets and
11 cost_under_limit
12}
13__
Working with Resources
1import "tfplan/v2" as tfplan
2
3# Get all resources of a specific type being created or modified
4get_resources = func(type) {
5 return filter tfplan.resource_changes as _, rc {
6 rc.type is type and
7 rc.mode is "managed" and
8 (rc.change.actions contains "create" or rc.change.actions contains "update")
9 }
10}
11
12# Example: Get all EC2 instances
13ec2_instances = get_resources("aws_instance")_____
Real-World Policy Examples
Let me share policies I've actually used in production. These solve problems that cost real money and cause real incidents.
Policy 1: Enforce Mandatory Tags
Every resource must have specific tags for cost allocation and ownership. This sounds simple, but it's one of the highest-value policies you can implement:
1# enforce-mandatory-tags.sentinel
2import "tfplan/v2" as tfplan
3import "strings"
4
5# Required tags
6required_tags = ["Environment", "Owner", "CostCenter", "Project"]
7
8# Resource types that support tags
9taggable_resources = [
10 "aws_instance",
11 "aws_s3_bucket",
12 "aws_rds_cluster",
13 "aws_lambda_function",
14 "aws_ecs_cluster",
15 "aws_eks_cluster",
16]
17
18# Get all taggable resources being created or updated
19get_taggable_resources = func() {
20 resources = {}
21 for taggable_resources as resource_type {
22 for tfplan.resource_changes as address, rc {
23 if rc.type is resource_type and
24 rc.mode is "managed" and
25 (rc.change.actions contains "create" or rc.change.actions contains "update") {
26 resources[address] = rc
27 }
28 }
29 }
30 return resources
31}
32
33# Check if resource has all required tags
34has_required_tags = func(resource) {
35 if resource.change.after.tags is null {
36 return false
37 }
38
39 tags = resource.change.after.tags
40 for required_tags as req_tag {
41 if tags[req_tag] is undefined or tags[req_tag] is "" {
42 return false
43 }
44 }
45 return true
46}
47
48# Validate all resources
49resources = get_taggable_resources()
50tag_violations = filter resources as address, resource {
51 not has_required_tags(resource)
52}
53
54# Print violations for debugging
55print_violations = func() {
56 if length(tag_violations) > 0 {
57 print("Resources missing required tags:", required_tags)
58 for tag_violations as address, _ {
59 print(" -", address)
60 }
61 }
62 return true
63}
64
65# Main rule
66main = rule {
67 print_violations() and
68 length(tag_violations) is 0
69}
70__
Policy 2: Restrict Instance Types
Control costs by limiting which instance types can be provisioned per environment:
1# restrict-instance-types.sentinel
2import "tfplan/v2" as tfplan
3
4# Allowed instance types by environment
5allowed_instance_types = {
6 "development": [
7 "t3.micro",
8 "t3.small",
9 "t3.medium",
10 ],
11 "staging": [
12 "t3.small",
13 "t3.medium",
14 "t3.large",
15 ],
16 "production": [
17 "t3.medium",
18 "t3.large",
19 "t3.xlarge",
20 "r5.large",
21 "r5.xlarge",
22 "m5.large",
23 "m5.xlarge",
24 ],
25}
26
27# Get environment from workspace name or variables
28get_environment = func() {
29 workspace = tfplan.variables.environment.value else "development"
30 return workspace
31}
32
33# Get EC2 instances being created or resized
34get_ec2_instances = func() {
35 return filter tfplan.resource_changes as _, rc {
36 rc.type is "aws_instance" and
37 rc.mode is "managed" and
38 (rc.change.actions contains "create" or rc.change.actions contains "update")
39 }
40}
41
42# Check instance type compliance
43check_instance_type = func(instance, env) {
44 instance_type = instance.change.after.instance_type
45 allowed = allowed_instance_types[env] else allowed_instance_types["development"]
46 return instance_type in allowed
47}
48
49# Evaluate
50environment = get_environment()
51instances = get_ec2_instances()
52
53violations = filter instances as address, instance {
54 not check_instance_type(instance, environment)
55}
56
57# Output violations
58print_violations = func() {
59 if length(violations) > 0 {
60 print("Environment:", environment)
61 print("Allowed instance types:", allowed_instance_types[environment])
62 print("Violations:")
63 for violations as address, instance {
64 print(" -", address, ":", instance.change.after.instance_type)
65 }
66 }
67 return true
68}
69
70main = rule {
71 print_violations() and
72 length(violations) is 0
73}
74__
Policy 3: Prevent Public S3 Buckets
This one prevents a common security mistake:
1# prevent-public-s3.sentinel
2import "tfplan/v2" as tfplan
3
4# Get S3 buckets and related resources
5get_s3_buckets = func() {
6 return filter tfplan.resource_changes as _, rc {
7 rc.type is "aws_s3_bucket" and
8 rc.mode is "managed" and
9 rc.change.actions contains "create"
10 }
11}
12
13get_bucket_acls = func() {
14 return filter tfplan.resource_changes as _, rc {
15 rc.type is "aws_s3_bucket_acl" and
16 rc.mode is "managed"
17 }
18}
19
20get_bucket_public_access_blocks = func() {
21 return filter tfplan.resource_changes as _, rc {
22 rc.type is "aws_s3_bucket_public_access_block" and
23 rc.mode is "managed"
24 }
25}
26
27# Check for public ACLs
28public_acls = [
29 "public-read",
30 "public-read-write",
31 "authenticated-read",
32]
33
34buckets = get_s3_buckets()
35bucket_acls = get_bucket_acls()
36public_access_blocks = get_bucket_public_access_blocks()
37
38# Check ACLs
39acl_violations = filter bucket_acls as address, acl {
40 acl.change.after.acl in public_acls
41}
42
43# Output
44print_violations = func() {
45 if length(acl_violations) > 0 {
46 print("Buckets with public ACLs (not allowed):")
47 for acl_violations as address, _ {
48 print(" -", address)
49 }
50 }
51 return true
52}
53
54main = rule {
55 print_violations() and
56 length(acl_violations) is 0
57}
58__
Policy 4: Cost Estimation Limits
Use Terraform Cloud's cost estimation to prevent expensive deployments:
1# limit-monthly-cost.sentinel
2import "tfrun"
3import "decimal"
4
5# Cost limits by workspace type
6cost_limits = {
7 "development": 500,
8 "staging": 2000,
9 "production": 10000,
10}
11
12# Default limit if environment unknown
13default_limit = 500
14
15# Get environment from workspace name
16get_environment = func() {
17 name = tfrun.workspace.name
18 if name matches ".*-production$" {
19 return "production"
20 } else if name matches ".*-staging$" {
21 return "staging"
22 }
23 return "development"
24}
25
26# Get the cost limit for current environment
27get_limit = func() {
28 env = get_environment()
29 return cost_limits[env] else default_limit
30}
31
32# Check if cost estimation is available
33cost_available = rule {
34 tfrun.cost_estimate is not null and
35 tfrun.cost_estimate.delta_monthly_cost is not null
36}
37
38# Calculate proposed monthly cost
39proposed_cost = decimal.new(tfrun.cost_estimate.proposed_monthly_cost else 0)
40limit = decimal.new(get_limit())
41
42# Main rules
43cost_under_limit = rule when cost_available {
44 proposed_cost.lte(limit)
45}
46
47# Print cost information
48print_cost_info = func() {
49 if tfrun.cost_estimate is not null {
50 print("Environment:", get_environment())
51 print("Monthly cost limit: $", get_limit())
52 print("Proposed monthly cost: $", tfrun.cost_estimate.proposed_monthly_cost)
53 print("Cost change: $", tfrun.cost_estimate.delta_monthly_cost)
54 }
55 return true
56}
57
58main = rule {
59 print_cost_info() and
60 cost_under_limit
61}
62______
Policy Sets Configuration
Organize policies into sets and apply them to workspaces:
1# sentinel.hcl
2policy "enforce-mandatory-tags" {
3 source = "./policies/enforce-mandatory-tags.sentinel"
4 enforcement_level = "hard-mandatory"
5}
6
7policy "restrict-instance-types" {
8 source = "./policies/restrict-instance-types.sentinel"
9 enforcement_level = "soft-mandatory"
10}
11
12policy "prevent-public-s3" {
13 source = "./policies/prevent-public-s3.sentinel"
14 enforcement_level = "hard-mandatory"
15}
16
17policy "limit-monthly-cost" {
18 source = "./policies/limit-monthly-cost.sentinel"
19 enforcement_level = "soft-mandatory"
20}_____
Testing Policies
Don't deploy untested policies. Sentinel CLI makes testing straightforward:
1# Install Sentinel CLI
2curl -o sentinel.zip https://releases.hashicorp.com/sentinel/0.24.0/sentinel_0.24.0_linux_amd64.zip
3unzip sentinel.zip
4sudo mv sentinel /usr/local/bin/
5
6# Run tests
7sentinel testCreate test cases with mock data to verify your policies work as expected before rolling them out.
______
Best Practices
- Start advisory, then enforce - Roll out new policies as advisory first. Let teams see failures without blocking them. Move to soft-mandatory, then hard-mandatory once violations are addressed.
- Write clear failure messages - Use
print()statements to explain exactly what failed and how to fix it. Frustrated developers ignore unclear policies. - Version control policies - Treat policies like code. PR reviews, testing, versioned releases. If it's not in git, it doesn't exist.
- Use modules for common logic - Extract reusable functions into Sentinel modules. Don't copy-paste filtering logic across policies.
- Test with real plan data - Export plan JSON from actual workspaces to create realistic mock data for tests.
- Document exceptions - When overrides happen, require comments explaining why. Review override patterns to improve policies over time.
_____
Wrapping Up
Sentinel transforms infrastructure governance from reactive firefighting to proactive prevention. Instead of discovering misconfigurations in production, you catch them before they're applied. Instead of arguing about standards in code reviews, you encode them as policy and enforce automatically.
The investment is writing policies that match your organization's requirements - cost limits, security baselines, compliance mandates. Once in place, enforcement is automatic and consistent across every Terraform run.
Start with a few high-impact policies: mandatory tags, cost limits, security basics. Expand as you identify patterns in the violations you're catching. Over time, you build a policy library that encodes your organization's infrastructure standards as code.
That's the promise of policy as code - and Sentinel delivers on it.
