Conftest for config & IaC
Policy on YAML, Terraform, Dockerfiles.
A spell-checker never asks you to retype the essay. You hand it the document, it reads every line, and it underlines the ones that break a rule. Conftest does that to the files that build your infrastructure. It takes structured config (YAML, JSON, Terraform's HCL, Dockerfiles, a rendered Helm chart), converts each one into JSON, and runs your Rego rules against that JSON as the document called input. Rego is the policy language of OPA (Open Policy Agent), the same language you will later write for admission control. If a deny rule fires, Conftest exits with a non-zero code, and CI (continuous integration, the checks that run automatically on every pull request) turns the build red. A pull request that adds a privileged container or a public load balancer dies before merge, judged by the very Rego you will hand to Gatekeeper later.
Install it, then run it once
Conftest ships as its own command-line tool with OPA compiled inside it, so there is no second install. Pin a release the way you pin any other build tool. Drop your .rego files in a directory (policy/ is the default it looks for), then point conftest test at a file or a whole folder. It finds the policies, evaluates every file you gave it, and prints one FAIL line per violation with the package name and your message. Exit code 1 is the signal your pipeline stops on.
conftest --version
Conftest: 0.55.0OPA: 1.0.0
conftest test deployment.yaml
FAIL - deployment.yaml - main - container app is privilegedFAIL - deployment.yaml - main - container app missing memory limit2 tests, 0 passed, 2 failures, 0 warnings
echo $?
1
Where policies live, and namespaces
Unless you say otherwise, your rules sit in package main and Conftest asks for data.main.deny. Send it somewhere else with --namespace, or hand it a different directory with --policy. Splitting by concern helps once you have more than a handful of rules: k8s.rego for manifests, terraform.rego for plans. Each file still has to define deny or warn rules, or Conftest finds nothing and cheerfully reports zero failures. Run opa test over the same directory first, so CI proves the policies work before it trusts them to block anything.
conftest test manifests/ --policy policy/ --all-namespaces
FAIL - manifests/prod/deployment.yaml - kubernetes - container uses :latest tag...5 tests, 3 passed, 2 failures, 0 warnings
Terraform plans and Helm renders
Terraform writes down what it is about to do before it does it, and Conftest can read that. Run terraform plan -out plan.bin, convert it with terraform show -json plan.bin > plan.json, then test the JSON. Conftest also parses raw HCL (HashiCorp Configuration Language, what .tf files are written in), but the plan is where the honest answers live, because variables and modules have already been resolved into real values. Helm works the same way one step earlier: helm template renders the chart into plain YAML, and Conftest scans the render. That catches a chart default nobody read, before the cluster ever sees it.
terraform show -json plan.bin > plan.jsonconftest test plan.json --policy policy/terraform/
FAIL - plan.json - terraform - S3 bucket will be public1 test, 0 passed, 1 failure, 0 warnings
helm template myapp ./chart > rendered.yamlconftest test rendered.yaml
FAIL - rendered.yaml - main - Deployment missing resource limits1 test, 0 passed, 1 failure, 0 warnings
deny blocks, warn does not
A deny rule that fires makes the run fail with exit code 1. A warn rule prints its message and the run still exits 0. That is handy during a rollout, when you want teams to see the message before it bites them. It is a quiet disaster when your pipeline only checks the exit code and someone wrote a blocking rule as warn. One detail matters here: deny is a set of message strings, not a single true or false answer. Every message that lands in that set becomes one more FAIL line, which is how Conftest can tell a developer all four things wrong with a manifest in a single run. A lone allow boolean could only ever say yes or no.
conftest test deployment.yaml # policy uses warn for :latest, deny for privileged
FAIL - deployment.yaml - main - container app is privilegedWARN - deployment.yaml - main - container uses :latest tag2 tests, 0 passed, 1 failure, 1 warning
Pull policy bundles from a registry
Container images get pushed to a registry and pulled by name and tag. Policy travels the same road. A platform team packs its Rego into a bundle and pushes it to an OCI registry (OCI is the Open Container Initiative, the standard your image registry already speaks). Then conftest pull oci://registry.internal/policies/k8s:v3 fetches it, and the next conftest test . runs against what was pulled. One bundle, signed once, gives fifty repositories the same guardrails instead of fifty copies of the same file drifting apart.
conftest pull oci://ghcr.io/acme/platform-policies:v2conftest test kubernetes/
5 tests, 5 passed, 0 failures, 0 warnings
Run opa test and Conftest together
The two tools answer different questions. opa test asks whether your Rego logic is right: given this made-up input, does the rule fire, and does it stay quiet on the clean case. Conftest asks whether real files pass. Run the unit tests first with opa test policy/, then run conftest test against a folder of sample files you keep on purpose, some clean and some deliberately broken. A rule that compiles but never fires gets caught by the unit test. A rule that fires on the wrong shape of file, because the parser handed it a JSON layout you did not expect, gets caught by the fixtures.
What your policy receives is the parsed file turned into JSON, and every parser produces a different shape. A Kubernetes manifest arrives as one object with kind and spec where you expect them. A Terraform plan arrives with everything buried under resource_changes. Guessing at field paths is how you end up with a rule that never fires. Print the whole input once with a throwaway policy, read the keys that are actually there, then write your conditions against those.
Monorepo runs
conftest test --all-namespaces apps/ infra/
# kubernetes + terraform packages in one invocation
When the job fails, print the Conftest output in the log exactly as it came out. Developers scan for FAIL lines, and a tidied-up summary that swallows the message wastes their time. Bring the policies in as a git submodule or a versioned OCI pull, so each application repository pins the policy version it was tested against instead of floating on whatever landed in main this morning.
conftest test --output github deployment.yaml
::error file=deployment.yaml::container app is privileged
Output formats your CI already understands
conftest test --output github turns each failure into an annotation GitHub pins to the offending file, and gitlab and junit do the same job for those systems. The developer reads the message beside the code instead of hunting through a log. On a monorepo where one pull request scans dozens of files, that is the difference between a review that moves and one that stalls.
Pin the policy version the way you pin a dependency. A POLICY_VERSION=v3 variable in the Makefile, used to build the oci://.../policies:$POLICY_VERSION reference, means you can check out a release branch from six months ago during an audit and get the same verdict CI gave back then. Without the pin, old code is judged by today's rules, and you will lose an afternoon explaining why.
Dockerfile rules pay for themselves early. USER root, an ADD that pulls from a URL nobody vetted, a base image from outside your registry: all of it is sitting in the file, and Conftest reads Dockerfiles natively. Keep those rules in a separate package docker.main, so a team that needs one Dockerfile exception does not switch off your Kubernetes rules at the same time.
Conftest passing while Gatekeeper rejects the same workload in production almost always means the two are reading different JSON. CI tested a rendered Deployment. Admission sees a request where the Pod fields sit at other paths. Serialize both documents to JSON and diff them with opa eval before you start blaming the policy.
conftest verify --policy policy/
Policies verified successfully
A pre-commit hook that runs conftest test over the staged YAML is fast enough on a small repository that nobody notices it. On a large monorepo it is not, so let CI carry that weight and use path filters, so a pull request only scans the directories it actually touched.
Write down what the parser cannot do. Conftest's HCL support trails the newest Terraform language features, so a rule can silently miss a resource written with syntax the parser did not understand. Pin the Terraform version in CI, and for the rules you genuinely care about, test the plan JSON rather than the raw .tf files.
Exceptions belong in data with an expiry date, never in a deny rule someone commented out. A time-bounded break_glass entry in data.audit_log, tied to a ticket ID, stops working on its own the day it should. A commented-out rule stops working when somebody remembers to uncomment it, which is never.
When a full monorepo scan takes longer than a developer will wait, split it into parallel CI jobs, one per directory. Shard by who owns what, so a red job names a team that can fix it. One giant job crawling ten thousand files tells you only that something, somewhere, is wrong.
Pin the Conftest version alongside the OPA version. Conftest carries its own copy of OPA inside, so if your laptop runs one build and CI runs another, a policy can pass locally and fail in the pipeline for reasons that have nothing to do with your rule. That is an hour of your life you never get back.
On merge request workflows, have the CI bot post the Conftest summary as a comment on the request. An exit code is a red icon somebody has to click. A comment naming the container that is missing a memory limit is a fix that happens the same afternoon.
Verify the checksum on the Conftest release you download. That binary runs inside CI holding your repository credentials, so a tampered build can quietly copy your manifests somewhere else. Same threat as a poisoned terraform or kubectl in the CI image, and it deserves the same care.
Give developers one command to run locally. An npm script or a just target wrapping conftest test ./manifests closes the gap where a check only ever breaks in CI. Friction is what pushes people to bypass the check, and bypassed checks are how a cluster ends up running workloads no policy ever looked at.
Developer templates
Put a policy-compliant deployment.yaml in the service scaffold, the cookiecutter or whatever your teams copy when they start something new. A new service then passes Conftest on its first commit, and the developer learns the rules from a working example instead of from a red CI log on day three.
Wire Conftest into the automated dependency bumps too. When Renovate or Dependabot rewrites the base image in a Dockerfile, run the same checks on that pull request, because a new base image can quietly bring USER root back with it.
Different services in one monorepo usually want different rule sets. Pass explicit --policy paths per service rather than aiming a single root directory at everything, which throws Terraform rules and Kubernetes rules into the same run. Choose package names with care as well, because the package name is part of the message a developer reads in the annotation.
Pick the output format that lands in the check your team already watches: -o github for pull request annotations, -o sarif (SARIF is the standard results format code-scanning dashboards read), -o junit for a test report. A red X labelled 'web missing memory limit' teaches the rule in three seconds. The same failure buried on line 400 of a shared runner's log teaches nothing.
With Helm, always render before you test. A chart on its own is a template full of placeholders, and the setting that breaks policy usually arrives from a values file or a flag on the command line. helm template resolves all of that into the YAML the cluster would really receive, and that is the document your rules should be judging.
Try this
Ten minutes, one rule. Write a deny that catches a container with no memory limit, drop a bare Deployment beside it, and run Conftest until the FAIL line appears. The thinking is identical to what you did with opa eval, aimed this time at a file your pipeline already produces.
mkdir -p policycat > policy/deployment.rego <<'EOF'package mainimport rego.v1deny contains msg if {input.kind == "Deployment"some c in input.spec.template.spec.containersnot c.resources.limits.memorymsg := sprintf("%v missing memory limit", [c.name])}EOFcat > deploy.yaml <<'EOF'apiVersion: apps/v1kind: Deploymentmetadata: {name: web}spec:template:spec:containers:- name: webimage: nginxEOFconftest test deploy.yaml --policy policy/conftest test deploy.yaml --policy policy/ -o stdout
FAIL - deploy.yaml - main - web missing memory limit1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
Takeaway
Conftest converts YAML, HCL, Dockerfiles and their relatives into JSON and runs your deny and warn rules on the pull request, long before anything reaches a cluster. deny is the hard gate that returns exit code 1. warn is for a rule you are still socialising, and it blocks precisely nothing until you add --fail-on-warn.
When several teams share the same rules, ship them as an OCI bundle and pin the tag in every repository that pulls it. And keep opa test running against the Rego itself, because Conftest can only tell you a file passed. It can never tell you the rule that should have caught it was written correctly.