Add Policy as Code to Terraform CI: Checkov + OPA/Conftest in GitHub Actions
Quick answer
Stop merging risky infrastructure. Wire two complementary gates into your Terraform pipeline — Checkov for out-of-the-box security scanning and OPA/Conftest for custom org governance evaluated against the plan JSON — and enforce them as required PR checks in GitHub Actions.
- Step 1: Run Checkov Locally
- Step 2: Scan the Plan, Not Just the HCL
- Step 3: Choose Soft-Fail vs Hard-Fail
- Step 4: Add a Custom Checkov Policy
- Step 5: Write Custom Governance with OPA and Conftest
intermediate · 60 min
Before you begin
- A Terraform or OpenTofu project in a GitHub repository
- Terraform/OpenTofu plus Docker (or the checkov and conftest CLIs) installed locally
- An existing GitHub Actions workflow you can extend
- Basic familiarity with reading HCL and JSON
Most Terraform reviews catch what a human notices in a diff. Policy as code catches what everyone misses on a Friday afternoon: a public S3 bucket, a security group open to 0.0.0.0/0, a resource in the wrong region, a missing cost-center tag. The fix isn't more careful reviewers — it's two automated gates in front of merge.
This tutorial wires up both halves of a real Terraform policy pipeline. Checkov (from Prisma Cloud/Bridgecrew) gives you hundreds of security and compliance checks out of the box with zero policy authoring. OPA + Conftest gives you custom organizational governance — the rules that are specific to your company, like "every resource carries a cost-center tag" or "we only deploy to eu-west-1 and eu-central-1" — evaluated against the Terraform plan JSON so you catch computed values, not just static HCL. If you want the conceptual comparison of these tools first, read Policy as Code for Terraform: OPA vs Sentinel vs Checkov.
One note on scope before we start: HashiCorp Sentinel is the other big name here, but it only runs inside HCP Terraform/Terraform Cloud and it does not run against OpenTofu. If you're on OpenTofu, or you want a gate that lives in your own CI regardless of backend, OPA/Conftest is the portable path — which is exactly what we build in Part B.
What You'll Build
- A Checkov scan that runs against your HCL and against a Terraform plan, with an understood soft-fail vs hard-fail posture
- One custom Checkov policy (YAML) for a rule Checkov doesn't ship
- A set of OPA/Conftest Rego policies (Rego v1 syntax) that enforce org governance against
terraform show -jsonoutput - A GitHub Actions workflow that runs both as required PR gates
- An understanding of the sharp edges: HCL vs plan JSON, false positives and suppressions, and unknown/computed values
Step 1: Run Checkov Locally
Checkov scans Terraform (and Kubernetes, CloudFormation, Helm, and more) with a large built-in ruleset. Install it and point it at your directory:
pip install checkov
checkov -d .checkov -d . walks the directory, parses the HCL, and prints every passed and failed check with an ID like CKV_AWS_18 and a link to the fix. By default Checkov exits non-zero when any check fails — that's your hard-fail behavior.
For a first run on an existing repo, you'll likely see a wall of findings. Get a compact summary and output you can archive:
checkov -d . --compact --output cli --output junitxml --output-file-path console,resultsThat prints a readable summary to the console and writes a results/results_junitxml.xml you can surface as a test report in CI.
Step 2: Scan the Plan, Not Just the HCL
Scanning raw HCL misses anything resolved at plan time — module outputs, interpolated values, defaults injected by the provider. The stronger signal is the plan JSON:
terraform init
terraform plan -out=tf.plan
terraform show -json tf.plan > plan.json
checkov -f plan.jsonCheckov understands the plan representation and evaluates resources with their fully-resolved attributes. Use -d . for fast pre-plan feedback and -f plan.json for the authoritative gate. (OpenTofu users: swap terraform for tofu — the -out / show -json flow is identical.)
Step 3: Choose Soft-Fail vs Hard-Fail
You almost never want to turn every finding into a hard blocker on day one — you'll never merge again. Checkov lets you split the difference:
# Hard-fail on high-severity checks, soft-fail (report, exit 0) on the rest
checkov -d . --hard-fail-on HIGH,CRITICAL --soft-fail-on MEDIUM,LOW
# Or soft-fail globally while you triage the backlog, then tighten later
checkov -d . --soft-fail--soft-fail makes Checkov exit 0 no matter what, so the scan is informational. --hard-fail-on / --soft-fail-on let you gate on severity. My recommendation: start with --soft-fail for a sprint to establish a baseline, then move to --hard-fail-on HIGH,CRITICAL once the loudest findings are fixed or explicitly suppressed.
Heads-up on severity gating: open-source Checkov's built-in checks don't carry a severity unless you connect it to a Prisma Cloud/Bridgecrew API key (
--bc-api-key) or the finding comes from a custom policy that setsseverity. Without that,--hard-fail-on HIGH,CRITICALmatches nothing and the gate fails open — a false sense of security. If you're not on the platform, gate on specific check IDs (--check CKV_AWS_18,...) or on Checkov's plain non-zero exit instead.
Suppress an individual false positive inline, right where a reviewer will see the justification:
resource "aws_s3_bucket" "logs" {
bucket = "my-org-access-logs"
# checkov:skip=CKV_AWS_18:Access logging bucket intentionally has no access logging (no infinite loop)
}Step 4: Add a Custom Checkov Policy
Checkov's built-in checks are generic. For a house rule it doesn't ship — say, "every S3 bucket must carry an Environment tag" — write a YAML policy. Create checkov_policies/require_environment_tag.yaml:
1metadata:
2 id: "CKV_ORG_1"
3 name: "S3 buckets must have an Environment tag"
4 category: "GENERAL_SECURITY"
5definition:
6 cond_type: "attribute"
7 resource_types:
8 - "aws_s3_bucket"
9 attribute: "tags.Environment"
10 operator: "exists"Load it alongside the built-in checks with --external-checks-dir:
checkov -d . --external-checks-dir checkov_policiesYAML policies cover attribute existence, equality, and connection-state rules. Anything more expressive (cross-resource logic, arithmetic) is where Python custom policies — or OPA/Conftest — take over.
Heads up: tfsec used to be the go-to scanner here, but it's been deprecated and folded into Trivy. If you prefer that ruleset, run
trivy config .(ortrivy config plan.json) instead of a second scanner. Checkov and Trivy overlap heavily; pick one to avoid duplicate noise. This tutorial standardizes on Checkov.
Step 5: Write Custom Governance with OPA and Conftest
Checkov answers "is this secure?" Conftest answers "does this match our rules?" Conftest is a thin CLI wrapper around the OPA Rego engine that evaluates policies against structured config — including Terraform plan JSON.
Generate the plan JSON exactly as in Step 2, then create a policy directory. Conftest looks in policy/ by default. Here's policy/terraform.rego using Rego v1 syntax — the default in OPA 1.0+ and current Conftest:
1package main
2
3import rego.v1
4
5# Only these AWS regions are approved for deployment.
6allowed_regions := {"eu-west-1", "eu-central-1"}
7
8# 1. Every taggable resource must carry a cost-center tag.
9deny contains msg if {
10 some rc in input.resource_changes
11 not "delete" in rc.change.actions
12 tags := object.get(rc.change.after, "tags", {})
13 not tags["cost-center"]
14 msg := sprintf("%s (%s) is missing the required 'cost-center' tag", [rc.address, rc.type])
15}
16
17# 2. Restrict deployments to approved regions (checks the provider config).
18deny contains msg if {
19 some rc in input.resource_changes
20 rc.type == "aws_instance"
21 region := rc.change.after.availability_zone
22 region != null
23 not region_allowed(region)
24 msg := sprintf("%s uses AZ %q outside approved regions", [rc.address, region])
25}
26
27# 3. No security group open to the world on SSH.
28deny contains msg if {
29 some rc in input.resource_changes
30 rc.type == "aws_security_group"
31 rule := rc.change.after.ingress[_]
32 rule.from_port <= 22
33 rule.to_port >= 22
34 "0.0.0.0/0" in rule.cidr_blocks
35 msg := sprintf("%s exposes SSH (port 22) to 0.0.0.0/0", [rc.address])
36}
37
38region_allowed(az) if {
39 some region in allowed_regions
40 startswith(az, region)
41}Key mechanics of this policy:
import rego.v1— on OPA 1.0+ the modern syntax (deny contains msg if { ... }instead of the legacydeny[msg] { ... }) is already the default, so this import is optional and effectively a no-op. Keep it if you want the same policy to also run on pre-1.0 OPA; on older OPA it's what opts you into the new syntax (import future.keywords.inis the older equivalent).- We iterate
input.resource_changes, the array Terraform's plan JSON exposes. Each entry has.address,.type, and.change.after(the resolved post-apply state). object.get(rc.change.after, "tags", {})defends against resources that have notagsblock at all, which would otherwise beundefinedand skip the rule silently.- We skip resources being deleted (
not "delete" in rc.change.actions) — no point demanding tags on something that's going away. (Don't writeactions[_] != "delete"here: that existential form is true whenever any action isn'tdelete, so it wrongly passes on a replace, which Terraform emits as["delete","create"].)
Step 6: Run Conftest Against the Plan
Run it via Docker (no local install) or the CLI:
# Docker
docker run --rm -v "$(pwd)":/project openpolicyagent/conftest \
test /project/plan.json
# Or the installed CLI
conftest test plan.jsonConftest loads every .rego under policy/, evaluates all deny (and warn) rules against plan.json, and exits non-zero if any deny fires. A clean run prints the number of tests passed; a violation prints your sprintf message and fails the command — which is exactly what CI needs.
Add a quick unit test for the policy itself so you trust the gate. Create policy/terraform_test.rego:
1package main
2
3import rego.v1
4
5test_denies_missing_cost_center if {
6 some msg in deny with input as {"resource_changes": [{
7 "address": "aws_s3_bucket.data",
8 "type": "aws_s3_bucket",
9 "change": {"actions": ["create"], "after": {"tags": {}}},
10 }]}
11 contains(msg, "cost-center")
12}Run it with conftest verify (or conftest verify -p policy). Testing your policies is the difference between a gate you trust and one people learn to ignore.
Step 7: Handle Unknown / Computed Values
The one thing that will bite you: plan JSON marks values that Terraform can't know until apply as unknown. They appear under change.after_unknown, and the key is simply absent from change.after. A naive deny that reads rc.change.after.some_field will silently pass (undefined) rather than fail — you get a false negative, not an error.
Decide your posture explicitly. To deny when a field is unknown (fail-closed), check after_unknown:
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_db_instance"
object.get(rc.change.after_unknown, "storage_encrypted", false) == true
msg := sprintf("%s: storage_encrypted is computed; set it explicitly so we can verify", [rc.address])
}For most tag/region rules, fail-open on unknowns is fine (the value usually is known at plan time). For security-critical fields like encryption, prefer fail-closed and require the value be set explicitly in HCL.
Step 8: Wire Both Gates into GitHub Actions
Now make it a required check. Create .github/workflows/policy.yml:
1name: Terraform Policy
2
3on:
4 pull_request:
5 paths:
6 - "**.tf"
7 - ".github/workflows/policy.yml"
8
9permissions:
10 contents: read
11
12jobs:
13 policy:
14 runs-on: ubuntu-latest
15 steps:
16 - uses: actions/checkout@v4
17
18 - name: Setup Terraform
19 uses: hashicorp/setup-terraform@v3
20
21 - name: Generate plan JSON
22 run: |
23 terraform init -input=false
24 terraform plan -out=tf.plan -input=false
25 terraform show -json tf.plan > plan.json
26
27 # Part A: Checkov security scan (gate on high severity, report the rest)
28 - name: Checkov
29 uses: bridgecrewio/checkov-action@v12
30 with:
31 file: plan.json
32 external_checks_dirs: checkov_policies
33 hard_fail_on: HIGH,CRITICAL
34 soft_fail_on: MEDIUM,LOW
35 output_format: cli,sarif
36 output_file_path: console,checkov.sarif
37
38 # Part B: Custom org governance via Conftest/OPA
39 - name: Conftest
40 run: |
41 # Conftest release assets embed the version, so resolve it first —
42 # there is no version-less "conftest_Linux_x86_64.tar.gz" (that URL 404s).
43 VER=$(curl -sSL https://api.github.com/repos/open-policy-agent/conftest/releases/latest \
44 | grep '"tag_name":' | sed -E 's/.*"v?([^"]+)".*/\1/')
45 curl -sSL "https://github.com/open-policy-agent/conftest/releases/download/v${VER}/conftest_${VER}_Linux_x86_64.tar.gz" \
46 | tar xz conftest
47 ./conftest verify -p policy
48 ./conftest test plan.json -p policyA few deliberate choices in this workflow:
terraform planneeds credentials. For real cloud resources, add an OIDC step (aws-actions/configure-aws-credentials@v4) before the plan and give the jobid-token: write. Never put long-lived keys in the workflow.- Checkov gates on
HIGH,CRITICALand reports the rest — matching the posture from Step 3. Uploadingcheckov.sarifto GitHub code scanning (a follow-ongithub/codeql-action/upload-sarifstep) surfaces findings inline on the PR. - Conftest runs
verifybeforetest— policy unit tests first, then the plan. If your Rego is broken, you find out fromverify, not from a confusing pass on real infra. - Mark both steps' check as required in Branch Protection (Settings → Branches) so a red gate actually blocks merge. A gate that isn't required is just a suggestion.
Common Issues
- Checkov floods the PR with findings on an existing repo. Start with
--soft-fail(orsoft_fail: truein the action) to establish a baseline, fix or suppress the top severities, then switch to--hard-fail-on HIGH,CRITICAL. Gating everything at once guarantees the team disables the check. - A Rego rule "passes" but the resource is clearly wrong. You're almost certainly reading a field that's unknown at plan time (see Step 7) — it's
undefined, so the rule short-circuits. Inspectplan.jsonforafter_unknownand decide fail-open vs fail-closed. - HCL scan and plan scan disagree. They should —
-d .sees static source,-f plan.jsonsees resolved values including provider defaults and module outputs. Treat the plan scan as authoritative and use the HCL scan for fast local feedback. - Conftest reports "no policies found." It defaults to
./policy; if your Rego lives elsewhere, pass-p <dir>. Also confirm every file haspackage main(or match the package with--namespace). - Legitimate finding you can't fix now. Use a scoped, commented suppression (
# checkov:skip=ID:reasonfor Checkov, awarninstead ofdenyin Rego) rather than disabling the whole check. The justification is the audit trail.
Frequently Asked Questions
Should I scan the HCL or the Terraform plan JSON?
Both, for different jobs. Scanning HCL (checkov -d .) is fast and needs no cloud credentials, so it's ideal for local pre-commit feedback. Scanning the plan JSON (checkov -f plan.json, conftest test plan.json) sees fully-resolved values — provider defaults, module outputs, interpolations — so it catches issues static HCL can't and is the authoritative CI gate. Run the HCL scan for speed and the plan scan for correctness.
Why use OPA/Conftest if Checkov already scans everything?
They solve different problems. Checkov ships hundreds of generic security checks you'd never want to write yourself. OPA/Conftest is for your organization's rules — required tags, approved regions, naming conventions, cross-resource constraints — expressed in Rego with full logical power. Use Checkov for the security baseline and Conftest for governance the vendor can't know about.
Do I need tfsec as well?
No. tfsec is deprecated and its engine was folded into Trivy, so if you want that ruleset run trivy config . or trivy config plan.json. Its coverage overlaps heavily with Checkov, so running both mostly produces duplicate findings and noise. Standardize on one security scanner (Checkov here) plus OPA/Conftest for custom policy.
Can I use this exact setup with OpenTofu?
Yes, and that's the point of choosing OPA/Conftest. Swap terraform for tofu — the plan -out and show -json commands are identical, and both Checkov and Conftest read the same plan JSON. HashiCorp Sentinel is the alternative, but it only runs inside HCP Terraform and does not support OpenTofu, so it's not portable. See OpenTofu vs Terraform for the broader tradeoffs.
How do I stop false positives from blocking every merge?
Layer your controls. Set a severity gate (--hard-fail-on HIGH,CRITICAL) so low-signal findings report without blocking. Suppress genuine false positives inline with a documented reason (# checkov:skip=ID:reason). In Rego, use warn for advisory rules and reserve deny for hard blockers. And always add policy unit tests (conftest verify) so you're confident the gate fails for the right reasons.
Tear Down
The gates are just files and a workflow — removing them is clean:
1# Disable the PR gate without deleting policies (soft-fail everything)
2# edit .github/workflows/policy.yml: set soft_fail: true and drop `conftest test`
3
4# Or remove the workflow and policies entirely
5rm .github/workflows/policy.yml
6rm -rf policy checkov_policies
7
8# Clean up local plan artifacts
9rm -f tf.plan plan.json checkov.sarifIf you made the checks required in Branch Protection, remember to unmark them (Settings → Branches) or new PRs will block on a workflow that no longer exists.
Official References
- Checkov Documentation — Installation, CLI flags, and the full built-in ruleset
- Checkov Custom Policies (YAML/Python) — Authoring house rules Checkov doesn't ship
- Conftest Documentation — Running Rego policies against Terraform plan JSON and other config
- OPA — Policy Language (Rego) — The Rego reference, including v1 syntax and built-in functions
- Terraform — JSON Plan Representation — The
resource_changesschema your Rego reads - Trivy — Misconfiguration Scanning — Where the former tfsec ruleset now lives
We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.
Struggling with this in production?
We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.