Verified13 commandsAI-assisted

Conftest

.md

Verified against Conftest 0.62.0 (bundles OPA 1.6.0), flags verified via `conftest --help` / · official docs

What it is and where it fits 🎯#

Conftest is the practical, config-testing-focused front end to OPA/Rego — "does this YAML/JSON/HCL/Dockerfile pass our policies" without hand-writing opa eval invocations or managing input-loading boilerplate. This is what most teams actually put in a CI pipeline for policy-as-code checks against Kubernetes manifests, Terraform plans, and Dockerfiles. If OPA is the engine, Conftest is the steering wheel and dashboard built for one specific, extremely common job.

Installation#

brew install conftest                          # macOS/Linux Homebrew
# or download a release tarball for your OS/arch from the GitHub releases page and extract the binary onto PATH
docker pull openpolicyagent/conftest            # containerized, no local install

conftest --version

Testing config files against policies#

conftest test deployment.yaml                          # policies default to a `policy/` directory in the cwd
conftest test --policy my-policies/ deployment.yaml     # explicit policy directory
conftest test k8s/                                       # test every file in a directory
conftest test --all-namespaces deployment.yaml            # evaluate every Rego namespace, not just `main`

Writing a policy#

# policy/deployment.rego
package main

deny[msg] if {
    input.kind == "Deployment"
    not input.spec.template.spec.securityContext.runAsNonRoot
    msg := sprintf("Deployment %s must set runAsNonRoot", [input.metadata.name])
}

warn[msg] if {
    input.kind == "Deployment"
    not input.spec.template.spec.containers[_].resources.limits
    msg := sprintf("Deployment %s has a container with no resource limits", [input.metadata.name])
}

deny rules are hard failures; warn rules are reported but don't fail the build unless --fail-on-warn is set — this split is the mechanism most Conftest policy sets use to separate "must block the merge" from "flag for review."

Loading supporting data#

conftest test --data policy/exceptions deployment.yaml
# a policy/exceptions/allowed_images.yaml file becomes available in Rego as `data.exceptions`
# policy/exceptions/allowed_images.yaml
allowed_registries:
  - "registry.internal.example.com"
deny[msg] if {
    input.kind == "Pod"
    image := input.spec.containers[_].image
    not startswith_any(image, data.exceptions.allowed_registries)
    msg := sprintf("image %s is not from an approved registry", [image])
}

Output formats#

conftest test -o table k8s/deployment.yaml
conftest test -o json k8s/
conftest test -o junit k8s/ > results.xml
conftest test -o sarif k8s/                     # for GitHub code scanning

Sample table output (real shape, from the tool's own documentation example):

+---------+----------------------------------+--------------------------------+ | RESULT | FILE | MESSAGE | +---------+----------------------------------+--------------------------------+ | success | examples/kubernetes/service.yaml | | | warning | examples/kubernetes/service.yaml | Found service hello-kubernetes | | | | but services are not allowed | +---------+----------------------------------+--------------------------------+

CI gating behavior#

conftest test k8s/                              # default: non-zero exit on any failure/warning
conftest test k8s/ --fail-on-warn                # also fail on warnings (deny is already a hard fail)
conftest test k8s/ --no-fail                     # always exit 0 — reporting-only mode

Note

Rego policies distinguish deny rules (hard fail, always non-zero exit) from warn rules (reported, but doesn't fail the build unless --fail-on-warn is set). This deny/warn split is the standard way most real Conftest policy bundles express severity, and is a cleaner mental model than remembering per-tool severity flag conventions the way you would for tfsec/Checkov/Trivy.

Debugging a policy 🔍#

conftest test --trace k8s/deployment.yaml                              # verbose Rego evaluation trace to stderr
conftest test --trace --output=json k8s/deployment.yaml 2>trace.log     # keep trace and formatted output separate
conftest parse k8s/deployment.yaml                                       # show the structured data Conftest extracted from the file, before any policy runs

Tip

conftest parse is the first thing to run when a policy "isn't matching" anything. It shows exactly what structured data Conftest handed to Rego — a policy written against input.spec.containers fails silently (not with an error) if the actual parsed structure nests things differently than expected, and parse is how you find that out in seconds instead of guessing.

Sharing policy bundles#

conftest pull oci://ghcr.io/org/policies:latest      # download policies from an OCI registry
conftest push oci://ghcr.io/org/policies:latest       # publish your policy/ directory as an OCI artifact
conftest test --update oci://ghcr.io/org/policies k8s/    # fetch the latest policy bundle, then test in one step

This is the mechanism a platform team uses to centrally publish and version an org-wide policy set that every application repo's CI pulls the latest version of, rather than each repo vendoring its own copy of the Rego files (which drifts).

Real-world scenario: enforcing a platform-wide "no privileged containers" rule#

A platform team wants every Kubernetes manifest across dozens of repos to fail CI if it requests a privileged container, with the policy centrally owned and updated in one place:

# policy/security.rego
package main

deny[msg] if {
    input.kind == "Pod"
    input.spec.containers[_].securityContext.privileged == true
    msg := "privileged containers are not permitted on this platform"
}
conftest push oci://ghcr.io/platform-team/k8s-policies:latest
# in every application repo's CI:
conftest test --update oci://ghcr.io/platform-team/k8s-policies k8s/

A policy update the platform team makes (tightening or relaxing a rule) takes effect on every consuming repo's next CI run automatically — no PR needed against each individual repo.

Real-world scenario: GitHub Actions CI recipe#

# .github/workflows/conftest.yml
name: Conftest
on: [pull_request]
jobs:
  policy-check:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - uses: instrumenta/conftest-action@master
        with:
          files: k8s/
          policy: policy/

Common pitfalls#

  • A policy silently matching nothing — see the conftest parse TIP above; this is by far the most common "why isn't my policy firing" report.
  • Forgetting --fail-on-warn when a warn rule was actually meant to block merges.
  • Vendoring policy files per-repo instead of using push/pull — makes a centrally-owned policy change require touching every consuming repo instead of taking effect automatically on next scan.

Exit codes#

0 all tests pass (or --no-fail set) · non-zero when a deny fires, or a warn fires with --fail-on-warn.

When to reach for something else#

For hand-authoring or unit-testing the underlying Rego itself (not just running it against config files), use opa test/opa eval directly — see the OPA cheat sheet. For Kubernetes-native admission control (rejecting a resource at kubectl apply time, not just in CI), the same Rego policies typically get wrapped in a Gatekeeper ConstraintTemplate rather than run through Conftest.