Lab: Enforcing Kubernetes Deployment Policies with OPA Conftest in CI/CD

Overview

Misconfigured Kubernetes manifests are one of the top causes of production security incidents. A container running as root, an unpinned image tag, a missing resource limit, or an exposed host network can each open the door to privilege escalation, resource exhaustion, or lateral movement inside your cluster.

The problem is that these misconfigurations are invisible until deployment time — or worse, until an attacker exploits them. The solution is to shift security left and catch policy violations before manifests ever reach the cluster.

In this hands-on lab, you will use Conftest — a testing framework built on the Open Policy Agent (OPA) engine — to write Rego policies that validate Kubernetes manifests. You will then integrate those checks into GitHub Actions and GitLab CI so that every pull request is automatically scanned for violations.

By the end of this lab you will have:

  • A library of reusable Rego policies covering image tags, security contexts, resource limits, and host-level access.
  • Unit tests for those policies using opa test.
  • Working CI/CD pipelines that block insecure manifests and provide clear, actionable violation messages.

Prerequisites

Before you begin, make sure you have the following tools and knowledge in place:

  • conftest CLI installed — install with Homebrew:
    brew install conftest

    Alternatively, download the binary from the Conftest releases page.

  • kubectl and a test cluster (optional) — if you want to verify that your fixed manifests actually deploy, spin up a local cluster with minikube start or kind create cluster.
  • A test repository — create a fresh Git repository or use an existing one. We will build all files from scratch.
  • Basic YAML and Kubernetes knowledge — you should be comfortable reading Deployment, Service, and Pod manifests.

Environment Setup

Start by creating the project structure and a set of intentionally insecure Kubernetes manifests. These will serve as our test fixtures throughout every exercise.

Project Structure

conftest-k8s-lab/
├── k8s/
│   ├── deployment-latest-tag.yaml
│   ├── deployment-run-as-root.yaml
│   ├── deployment-no-limits.yaml
│   ├── service-loadbalancer.yaml
│   └── pod-host-network.yaml
└── policy/

Create the directories:

mkdir -p conftest-k8s-lab/k8s conftest-k8s-lab/policy
cd conftest-k8s-lab

Manifest 1 — Deployment with Unpinned Image Tag

Create k8s/deployment-latest-tag.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-latest
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web-latest
  template:
    metadata:
      labels:
        app: web-latest
    spec:
      containers:
        - name: nginx
          image: nginx:latest
          ports:
            - containerPort: 80

This manifest uses nginx:latest, which means every pull could silently introduce a different binary into your cluster.

Manifest 2 — Deployment Running as Root

Create k8s/deployment-run-as-root.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-root
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web-root
  template:
    metadata:
      labels:
        app: web-root
    spec:
      containers:
        - name: nginx
          image: nginx:1.25.4
          ports:
            - containerPort: 80

No securityContext is set, so the container defaults to running as root — a well-known privilege escalation vector.

Manifest 3 — Deployment with No Resource Limits

Create k8s/deployment-no-limits.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-no-limits
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web-no-limits
  template:
    metadata:
      labels:
        app: web-no-limits
    spec:
      containers:
        - name: nginx
          image: nginx:1.25.4
          ports:
            - containerPort: 80

Without CPU and memory limits a single misbehaving pod can starve the entire node.

Manifest 4 — Service of Type LoadBalancer

Create k8s/service-loadbalancer.yaml:

apiVersion: v1
kind: Service
metadata:
  name: web-lb
spec:
  type: LoadBalancer
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 80

A bare LoadBalancer service with no annotations can expose workloads to the public internet in cloud environments.

Manifest 5 — Pod with Host Network Access

Create k8s/pod-host-network.yaml:

apiVersion: v1
kind: Pod
metadata:
  name: debug-pod
spec:
  hostNetwork: true
  containers:
    - name: debug
      image: busybox:1.36
      command: ["sleep", "3600"]

hostNetwork: true gives the pod full access to the node’s network stack, bypassing network policies entirely.

Exercise 1: Write Your First Rego Policy — No Latest Tags

Your first policy will deny any container image that uses the :latest tag or omits a tag entirely (which also resolves to latest).

Step 1 — Create the Policy

Create policy/tags.rego:

package main

import future.keywords.in

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  image := container.image
  not contains(image, ":")
  msg := sprintf("Container '%s' uses image '%s' without a tag. Pin to a specific version.", [container.name, image])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  image := container.image
  endswith(image, ":latest")
  msg := sprintf("Container '%s' uses the ':latest' tag in image '%s'. Pin to a specific version.", [container.name, image])
}

Step 2 — Run Conftest Against the Insecure Manifest

conftest test k8s/deployment-latest-tag.yaml

Expected output:

FAIL - k8s/deployment-latest-tag.yaml - main - Container 'nginx' uses the ':latest' tag in image 'nginx:latest'. Pin to a specific version.

1 test, 0 passed, 0 warnings, 1 failure

Step 3 — Fix the Manifest

Edit k8s/deployment-latest-tag.yaml and change the image line:

          image: nginx:1.25.4

Run Conftest again:

conftest test k8s/deployment-latest-tag.yaml

Expected output:

1 test, 1 passed, 0 warnings, 0 failures

Understanding the Rego Structure

Every Rego policy file used by Conftest follows a simple pattern:

  • package main — Conftest looks for the main package by default. You can override this with --namespace.
  • deny[msg] — a rule set. If all conditions inside the rule body evaluate to true, the rule fires and adds msg to the set of violations.
  • input — represents the YAML document being tested. Conftest parses it into a JSON object automatically.
  • sprintf — formats a human-readable error message that appears in CI logs.

Exercise 2: No Containers Running as Root

Containers that run as root can modify the filesystem, install packages, and — if combined with a kernel exploit — escape to the host. This policy enforces two controls: runAsNonRoot: true and allowPrivilegeEscalation: false.

Step 1 — Create the Policy

Create policy/security_context.rego:

package main

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.securityContext.runAsNonRoot == true
  msg := sprintf("Container '%s' must set securityContext.runAsNonRoot to true.", [container.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.securityContext.allowPrivilegeEscalation == false
  msg := sprintf("Container '%s' must set securityContext.allowPrivilegeEscalation to false.", [container.name])
}

Step 2 — Test Against the Insecure Manifest

conftest test k8s/deployment-run-as-root.yaml

Expected output:

FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.runAsNonRoot to true.
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.allowPrivilegeEscalation to false.

1 test, 0 passed, 0 warnings, 2 failures

Step 3 — Fix the Manifest

Update k8s/deployment-run-as-root.yaml to include a security context on each container:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-root
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web-root
  template:
    metadata:
      labels:
        app: web-root
    spec:
      containers:
        - name: nginx
          image: nginx:1.25.4
          ports:
            - containerPort: 80
          securityContext:
            runAsNonRoot: true
            allowPrivilegeEscalation: false

Run Conftest again — both rules now pass.

Exercise 3: Require Resource Limits

Without resource limits, a single container can consume all CPU and memory on the node, causing cascading failures across unrelated workloads. Many compliance frameworks (SOC 2, CIS Benchmarks) require explicit limits.

Step 1 — Create the Policy

Create policy/resources.rego:

package main

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.cpu
  msg := sprintf("Container '%s' must define resources.limits.cpu.", [container.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.resources.limits.memory
  msg := sprintf("Container '%s' must define resources.limits.memory.", [container.name])
}

Step 2 — Test Against the Insecure Manifest

conftest test k8s/deployment-no-limits.yaml

Expected output:

FAIL - k8s/deployment-no-limits.yaml - main - Container 'nginx' must define resources.limits.cpu.
FAIL - k8s/deployment-no-limits.yaml - main - Container 'nginx' must define resources.limits.memory.

1 test, 0 passed, 0 warnings, 2 failures

Step 3 — Fix the Manifest

Add resource limits to k8s/deployment-no-limits.yaml:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-no-limits
spec:
  replicas: 1
  selector:
    matchLabels:
      app: web-no-limits
  template:
    metadata:
      labels:
        app: web-no-limits
    spec:
      containers:
        - name: nginx
          image: nginx:1.25.4
          ports:
            - containerPort: 80
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "250m"
              memory: "256Mi"

Run Conftest again — both CPU and memory checks pass.

Exercise 4: Deny Privileged Host Access

Pods that request host-level access — hostNetwork, hostPID, hostIPC, or a privileged security context — effectively run outside the container sandbox. A compromised pod with any of these flags can see all traffic on the node, attach to other processes, or escape to the host entirely.

Step 1 — Create the Policy

Create policy/host_access.rego:

package main

deny[msg] {
  input.kind == "Pod"
  input.spec.hostNetwork == true
  msg := sprintf("Pod '%s' must not use hostNetwork: true.", [input.metadata.name])
}

deny[msg] {
  input.kind == "Pod"
  input.spec.hostPID == true
  msg := sprintf("Pod '%s' must not use hostPID: true.", [input.metadata.name])
}

deny[msg] {
  input.kind == "Pod"
  input.spec.hostIPC == true
  msg := sprintf("Pod '%s' must not use hostIPC: true.", [input.metadata.name])
}

deny[msg] {
  input.kind == "Pod"
  container := input.spec.containers[_]
  container.securityContext.privileged == true
  msg := sprintf("Container '%s' in Pod '%s' must not run in privileged mode.", [container.name, input.metadata.name])
}

deny[msg] {
  input.kind == "Deployment"
  input.spec.template.spec.hostNetwork == true
  msg := sprintf("Deployment '%s' must not use hostNetwork: true.", [input.metadata.name])
}

deny[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  container.securityContext.privileged == true
  msg := sprintf("Container '%s' in Deployment '%s' must not run in privileged mode.", [container.name, input.metadata.name])
}

Step 2 — Test Against the Insecure Pod

conftest test k8s/pod-host-network.yaml

Expected output:

FAIL - k8s/pod-host-network.yaml - main - Pod 'debug-pod' must not use hostNetwork: true.

1 test, 0 passed, 0 warnings, 1 failure

Step 3 — Fix the Manifest

Remove the hostNetwork: true line from k8s/pod-host-network.yaml:

apiVersion: v1
kind: Pod
metadata:
  name: debug-pod
spec:
  containers:
    - name: debug
      image: busybox:1.36
      command: ["sleep", "3600"]

Run Conftest — the pod now passes all host-access checks.

Exercise 5: Testing Policies with opa test

Policies are code, and code needs tests. Without tests you cannot be sure that a policy catches what it should or that a future refactor does not introduce a false positive that blocks legitimate deployments.

Step 1 — Create Test Cases

Create policy/tags_test.rego:

package main

test_latest_denied {
  input := {
    "kind": "Deployment",
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "app",
              "image": "nginx:latest"
            }
          ]
        }
      }
    }
  }
  count(deny) > 0
}

test_no_tag_denied {
  input := {
    "kind": "Deployment",
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "app",
              "image": "nginx"
            }
          ]
        }
      }
    }
  }
  count(deny) > 0
}

test_pinned_allowed {
  input := {
    "kind": "Deployment",
    "spec": {
      "template": {
        "spec": {
          "containers": [
            {
              "name": "app",
              "image": "nginx:1.25.4"
            }
          ]
        }
      }
    }
  }
  count(deny) == 0
}

Step 2 — Run the Tests

opa test policy/ -v

Expected output:

policy/tags_test.rego:
data.main.test_latest_denied: PASS (1.234ms)
data.main.test_no_tag_denied: PASS (0.567ms)
data.main.test_pinned_allowed: PASS (0.432ms)
--------------------------------------------------------------------------------
PASS: 3/3

Why Policy Testing Matters

In a CI/CD context, a false negative means an insecure manifest slips through, while a false positive blocks a legitimate deployment and erodes developer trust in the pipeline. By writing explicit test cases for both allowed and denied inputs, you get a regression suite that runs in milliseconds and guarantees your policies behave correctly as the rule library grows.

Make it a habit to add a *_test.rego file for every new policy file. Run opa test policy/ -v as part of your CI pipeline alongside conftest test.

Exercise 6: Integrate Conftest into GitHub Actions

With policies written and tested, the next step is to wire them into your CI pipeline so that every pull request is automatically validated.

Step 1 — Create the Workflow

Create .github/workflows/policy-check.yml:

name: Kubernetes Policy Check

on:
  pull_request:
    paths:
      - "k8s/**"
      - "policy/**"
  push:
    branches: [main]
    paths:
      - "k8s/**"
      - "policy/**"

jobs:
  conftest:
    name: Validate K8s Manifests
    runs-on: ubuntu-latest
    steps:
      - name: Checkout repository
        uses: actions/checkout@v4

      - name: Install Conftest
        run: |
          CONFTEST_VERSION="0.56.0"
          wget -q "https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
          tar xzf "conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
          sudo mv conftest /usr/local/bin/
          conftest --version

      - name: Install OPA
        run: |
          OPA_VERSION="v0.68.0"
          curl -L -o opa "https://openpolicyagent.org/downloads/${OPA_VERSION}/opa_linux_amd64_static"
          chmod +x opa
          sudo mv opa /usr/local/bin/
          opa version

      - name: Run policy unit tests
        run: opa test policy/ -v

      - name: Run Conftest against all manifests
        run: |
          echo "Scanning all Kubernetes manifests in k8s/ ..."
          FAILED=0
          for file in k8s/*.yaml; do
            echo ""
            echo "--- Testing: $file ---"
            if ! conftest test "$file" --policy policy/; then
              FAILED=1
            fi
          done
          if [ "$FAILED" -eq 1 ]; then
            echo ""
            echo "❌ One or more manifests violated policy. Fix the issues above."
            exit 1
          fi
          echo ""
          echo "✅ All manifests passed policy checks."

Step 2 — Observe a Failing PR

Push a branch that contains the original insecure manifests. The pipeline output will look like this:

--- Testing: k8s/deployment-latest-tag.yaml ---
FAIL - k8s/deployment-latest-tag.yaml - main - Container 'nginx' uses the ':latest' tag in image 'nginx:latest'. Pin to a specific version.

--- Testing: k8s/deployment-run-as-root.yaml ---
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.runAsNonRoot to true.
FAIL - k8s/deployment-run-as-root.yaml - main - Container 'nginx' must set securityContext.allowPrivilegeEscalation to false.

--- Testing: k8s/pod-host-network.yaml ---
FAIL - k8s/pod-host-network.yaml - main - Pod 'debug-pod' must not use hostNetwork: true.

❌ One or more manifests violated policy. Fix the issues above.
Error: Process completed with exit code 1.

The PR status check turns red with clear violation messages that tell the developer exactly what to fix and where.

Step 3 — Observe a Passing PR

Fix all manifests as shown in the previous exercises, push again, and the pipeline passes:

--- Testing: k8s/deployment-latest-tag.yaml ---
1 test, 1 passed, 0 warnings, 0 failures

--- Testing: k8s/deployment-run-as-root.yaml ---
1 test, 1 passed, 0 warnings, 0 failures

--- Testing: k8s/deployment-no-limits.yaml ---
1 test, 1 passed, 0 warnings, 0 failures

✅ All manifests passed policy checks.

Exercise 7: Integrate Conftest into GitLab CI

If your team uses GitLab, the integration is just as straightforward. Add the following job to your .gitlab-ci.yml:

Full Working Configuration

stages:
  - validate

conftest-policy-check:
  stage: validate
  image: alpine:3.19
  variables:
    CONFTEST_VERSION: "0.56.0"
    OPA_VERSION: "v0.68.0"
  before_script:
    - apk add --no-cache curl wget tar
    - wget -q "https://github.com/open-policy-agent/conftest/releases/download/v${CONFTEST_VERSION}/conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
    - tar xzf "conftest_${CONFTEST_VERSION}_Linux_x86_64.tar.gz"
    - mv conftest /usr/local/bin/
    - curl -L -o /usr/local/bin/opa "https://openpolicyagent.org/downloads/${OPA_VERSION}/opa_linux_amd64_static"
    - chmod +x /usr/local/bin/opa
  script:
    - echo "Running policy unit tests..."
    - opa test policy/ -v
    - echo "Running Conftest against all manifests..."
    - |
      FAILED=0
      for file in k8s/*.yaml; do
        echo ""
        echo "--- Testing: $file ---"
        if ! conftest test "$file" --policy policy/; then
          FAILED=1
        fi
      done
      if [ "$FAILED" -eq 1 ]; then
        echo ""
        echo "One or more manifests violated policy."
        exit 1
      fi
      echo ""
      echo "All manifests passed policy checks."
  rules:
    - changes:
        - k8s/**/*
        - policy/**/*
      when: always

Pass/Fail Behavior

The behavior mirrors the GitHub Actions workflow exactly. When insecure manifests are present, the job fails with violation messages. When all manifests are compliant, the job passes with a clean summary. The rules block ensures the job only runs when Kubernetes manifests or policy files change, keeping pipeline runtime minimal.

Advanced: Warnings vs. Denials

Not every policy violation should block a deployment. Some are recommendations — best practices that you want to surface without breaking the pipeline. Conftest supports this distinction through warn rules.

How It Works

  • deny[msg] — a hard gate. If any deny rule fires, conftest test exits with a non-zero code and the pipeline fails.
  • warn[msg] — an advisory. The message is printed but the exit code remains zero, so the pipeline passes.
  • conftest test --fail-on-warn — optionally promotes all warnings to failures. Useful when you want to gradually tighten policy: start with warn, and once teams have fixed existing violations, switch to deny or enable --fail-on-warn.

Create an Advisory Policy

Create policy/recommendations.rego:

package main

warn[msg] {
  input.kind == "Service"
  input.spec.type == "LoadBalancer"
  not input.metadata.annotations
  msg := sprintf("Service '%s' is of type LoadBalancer with no annotations. Consider adding cloud-provider-specific annotations for internal load balancers.", [input.metadata.name])
}

warn[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.readinessProbe
  msg := sprintf("Container '%s' has no readinessProbe. Add one so Kubernetes can route traffic only to healthy pods.", [container.name])
}

warn[msg] {
  input.kind == "Deployment"
  container := input.spec.template.spec.containers[_]
  not container.livenessProbe
  msg := sprintf("Container '%s' has no livenessProbe. Add one so Kubernetes can restart unhealthy pods.", [container.name])
}

Test the Advisory Policy

conftest test k8s/service-loadbalancer.yaml

Expected output:

WARN - k8s/service-loadbalancer.yaml - main - Service 'web-lb' is of type LoadBalancer with no annotations. Consider adding cloud-provider-specific annotations for internal load balancers.

1 test, 1 passed, 1 warning, 0 failures

Note: the exit code is 0 — the pipeline still passes. If you want to enforce warnings:

conftest test k8s/service-loadbalancer.yaml --fail-on-warn

Now the exit code is 1 and the pipeline would fail.

This pattern lets you roll out new policies gradually: introduce them as warnings, give teams time to remediate, then promote to denials.

Cleanup

When you are finished with the lab, remove the test resources:

# Remove the lab directory
rm -rf conftest-k8s-lab

# If you deployed any fixed manifests to a test cluster
kubectl delete -f k8s/ --ignore-not-found

# If you created a kind cluster for this lab
kind delete cluster --name conftest-lab

Key Takeaways

  • Shift left aggressively. Catching a misconfigured manifest in a pull request is orders of magnitude cheaper than discovering it after a breach.
  • Conftest + Rego is a lightweight entry point to policy-as-code. You do not need a full OPA server or Gatekeeper installation to start enforcing policies — a single CLI binary and a few Rego files are enough.
  • Test your policies like application code. Use opa test with explicit positive and negative test cases to prevent regressions in your rule library.
  • Use warnings for gradual rollout. Start new policies as warn rules, socialize them with the team, and promote to deny once existing violations are resolved.
  • Actionable error messages are critical. Use sprintf in every rule to tell the developer which container, which field, and what to do about it. Generic “policy violated” messages erode trust in CI gates.
  • Keep policies in the same repository as the manifests. Co-locating policy/ with k8s/ means policy changes go through the same review process as infrastructure changes.

Next Steps

Now that you have a working Conftest pipeline, continue building your policy-as-code practice: