CoursesAtlantisPolicy checks in the flow

Policy checks in the flow

Conftest/OPA gating applies.

Advanced12 min · lesson 8 of 12

A security scanner bolted onto CI (continuous integration, the automated build that runs on every code change) is a building inspector who mails you a report. You can read it, file it, and pour the concrete anyway. An Atlantis policy check is the inspector standing on site holding the crane keys. The same server that keeps your cloud credentials and runs terraform apply reads every plan against your rules first. If a mandatory rule fails, nobody gets the keys until the code changes or a named owner signs the exception, in public, on the pull request.

Three names to get straight. Open Policy Agent (OPA) is a general-purpose policy engine: you hand it data, it tells you whether that data is allowed. Rego is the language you write those rules in. Conftest is a CLI (command-line interface tool) that runs Rego rules against structured files, including the JSON form of a Terraform plan. Most scanners comment and walk away. Atlantis wires Conftest in as a real pipeline stage, inside the one process that can actually change your infrastructure.

What actually runs: your plan as JSON, judged by Rego

Switch policy checks on and Atlantis slips a policy_check stage between plan and apply for every project. The default stage is two steps. First show runs terraform show -json against the saved plan file and writes the result to a path exposed as $SHOWFILE. Then policy_check runs the pinned Conftest version against $SHOWFILE, once per configured policy set. Rules live in the main package unless you say otherwise: deny rules block, warn rules annotate. That is the whole machine, which is exactly why you can rebuild it on your laptop.

policies/s3.rego
package main
# keeps this parseable on any Conftest — the modern deny/warn syntax
# below is only the DEFAULT dialect since Conftest 0.60
import rego.v1
# mandatory: a matching deny blocks `atlantis apply`
deny contains msg if {
some rc in input.resource_changes
rc.type == "aws_s3_bucket_acl"
rc.change.after.acl == "public-read"
msg := sprintf("%s: public-read ACL is not allowed", [rc.address])
}
# advisory: a warn annotates the PR but never blocks
warn contains msg if {
some rc in input.resource_changes
rc.change.after.tags != null
not rc.change.after.tags["cost-center"]
msg := sprintf("%s: missing cost-center tag", [rc.address])
}

The rules read the plan's resource_changes array. That array is Terraform's own answer to "what am I about to do", written down after modules resolve and variables interpolate. It beats linting HCL (HashiCorp Configuration Language, the .tf syntax you type) for one reason. A public ACL (access control list, the setting that decides who may read a bucket) gets caught whether someone typed it directly, passed it through three layers of modules, or computed it from a variable.

reproduce-the-stage-locally.sh
# Atlantis's policy_check stage is essentially three commands —
# reproduce any PR failure at your desk before blaming the server.
terraform init -input=false
terraform plan -out tfplan.binary
terraform show -json tfplan.binary > tfplan.json # what $SHOWFILE holds
conftest test tfplan.json --policy policies/
# FAIL - tfplan.json - main - aws_s3_bucket_acl.assets: public-read ACL is not allowed
# WARN - tfplan.json - main - aws_s3_bucket.assets: missing cost-center tag
# 2 tests, 0 passed, 1 warning, 1 failure, 0 exceptions
# unit-test the policies themselves (catches Rego typos before they
# brick every PR on the server):
conftest verify --policy policies/
# 4 tests, 4 passed, 0 warnings, 0 failures, 0 exceptions

That parity is your debugging superpower. When a check fails on a pull request and nobody can say why, pull the branch, run those three commands, and you are looking at exactly what the server looked at. No log spelunking required.

Turning the gate on, and why it lives on the server

Two pieces have to exist, and neither does anything alone. The --enable-policy-checks server flag (or the environment variable ATLANTIS_ENABLE_POLICY_CHECKS=true) turns the stage on. The policies block in the *server-side repo config*, the repos.yaml file you hand to --repo-config, says what runs and who may grant exceptions. Define policies with no flag and nothing happens. Set the flag with no policy sets and there is nothing to enforce.

/etc/atlantis/repos.yaml
# Server-side repo config. Lives on the Atlantis host —
# nothing in a pull request can edit or disable it.
repos:
- id: github.com/acme/*
branch: /^main$/
apply_requirements: [approved, mergeable, undiverged]
policies:
conftest_version: 0.68.2 # pin it — Conftest 0.60 flipped the
# default Rego dialect to OPA 1.0's
approve_count: 1 # approvals needed per failing policy set
owners:
users: [security-lead] # the only accounts approve_policies obeys
policy_sets:
- name: security-baseline
path: /policies # directory of .rego files on the SERVER
source: local
prevent_self_approve: true # PR author can't bless their own failure
# The stage itself is switched on by the server, not this file:
# atlantis server --enable-policy-checks \
# --repo-config /etc/atlantis/repos.yaml ...
# (or env: ATLANTIS_ENABLE_POLICY_CHECKS=true)

Notice which file is *not* invited to configure any of this: the repo's own atlantis.yaml. That is deliberate, and it is the same trust boundary that fences off custom run steps. Anything a pull request can edit is controlled by the person you are policing. If policy sets lived repo-side, step one of every bypass would be "delete the policy". Server-side, a pull request can break a rule but it can never rewrite one. owners lists the accounts whose atlantis approve_policies comment actually counts. approve_count sets how many of them must sign. prevent_self_approve, set per policy set, stops an author waving through their own failure.

The gate on a real pull request: fail, approve, apply

Here is the full lifecycle on a pull request that adds a public S3 bucket ACL. Autoplan produces the plan, the policy check fails, an eager atlantis apply bounces off the gate, a policy owner signs the exception, and only then does the apply run:

pr-timeline.txt
you git push → autoplan fires
atlantis Ran Plan for dir: `prod/s3` workspace: `default`
Plan: 2 to add, 0 to change, 0 to destroy.
atlantis Ran Policy Check for dir: `prod/s3` workspace: `default`
security-baseline:
FAIL - tfplan.json - main - aws_s3_bucket_acl.assets:
public-read ACL is not allowed
1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions
* To approve failing policies an authorized approver can
comment: `atlantis approve_policies`
* Or, address the policy failure and re-plan.
you atlantis apply
atlantis Apply Failed: All policies must pass for project
before running apply.
sec-lead atlantis approve_policies
atlantis Approved Policies for 1 projects: prod/s3
you atlantis apply
atlantis Apply complete! Resources: 2 added, 0 changed, 0 destroyed.

Two details are worth slowing down for. The approval is tied to *this exact plan*. Push one more commit, autoplan runs again, the policy check re-evaluates, and every earlier approval is wiped. And all of it, the failure, the owner's sign-off, the apply, lands as a permanent pull request comment. When an auditor asks who allowed the public bucket and why, the answer is a link rather than an archaeology dig. On a busy monorepo, add --quiet-policy-checks so passing checks stay silent and only failures make noise.

The policy gate branches on the plan's verdict
Policy check on the saved plan
Conftest runs deny & warn Rego against terraform show -json ($SHOWFILE)
no deny rule matches
Apply proceeds
same server, same credentials, every step recorded as a permanent PR comment
a deny rule fails
Apply is blocked
fix the code and re-plan, or a named owner comments atlantis approve_policies
only warn rules match
PR annotated, never blocked
advisory notes never gate; a policy that only comments is a suggestion with a logo
The gate re-arms on every plan: a new commit re-runs the check and wipes prior approvals, so the verdict always matches the exact plan that will be applied.

Running it on Kubernetes with the Helm chart

In production most teams run Atlantis from the official runatlantis/atlantis Helm chart, which deploys a StatefulSet with a persistent volume for plan data and locks. Policy checking adds two requirements to that deployment and no more: the server needs the flag, and the Rego files have to sit on the pod's filesystem at whatever path your policy sets name. Mounting a ConfigMap (a Kubernetes object that holds plain text and hands it to a pod as real files) is the easiest way in:

values.yaml
orgAllowlist: github.com/acme/*
environment:
ATLANTIS_ENABLE_POLICY_CHECKS: "true"
# mount the Rego bundle where repoConfig's `path` expects it
extraVolumes:
- name: policies
configMap:
name: atlantis-policies
extraVolumeMounts:
- name: policies
mountPath: /policies
readOnly: true
repoConfig: |
---
repos:
- id: github.com/acme/*
policies:
approve_count: 1
owners:
users: [security-lead]
policy_sets:
- name: security-baseline
path: /policies
source: local
prevent_self_approve: true
deploy.sh
helm repo add runatlantis https://runatlantis.github.io/helm-charts
kubectl create namespace atlantis
kubectl -n atlantis create configmap atlantis-policies --from-file=policies/
# configmap/atlantis-policies created
helm upgrade --install atlantis runatlantis/atlantis \
-n atlantis -f values.yaml
# Release "atlantis" does not exist. Installing it now.
# verify the Rego actually landed where `path` points:
kubectl -n atlantis exec statefulset/atlantis -- ls /policies
# s3.rego

The ConfigMap route buys you fast iteration. Edit the Rego, re-create the ConfigMap, restart the pod. It also carries a 1 MiB (mebibyte) size ceiling, and unless the ConfigMap itself is managed through Git, there is no review trail on the rules that gate everyone else. The hardened alternative is baking /policies into a custom image: immutable, scannable, versioned alongside the server, at the price of an image build for every policy change. Either way, keep the policies in their own reviewed repository. Guard rules deserve the same scrutiny as the code they guard.

What the gate cannot see, and the habits that keep it honest

Start with the blind spots. The gate judges a *plan*, so any value marked "known after apply" arrives as unknown in the JSON, and a rule that inspects a computed attribute can pass without noticing a thing. For fields that carry security weight, deny when the value is missing or unknown, not only when it is provably bad. The gate also fires only when Terraform runs through Atlantis. Someone clicking around in the cloud console, or drift that shows up overnight, stays invisible to it. That is why this sits alongside runtime scanning rather than in place of it.

The engine is its own failure domain. One Rego syntax error in a policy set can fail the policy check for every project on the server. Two habits keep that from ruining a morning. Pin conftest_version so an upgrade never quietly reinterprets your rules (Conftest 0.60 flipped the default Rego dialect to OPA 1.0's, which rejects policies nobody has migrated). And run conftest verify unit tests in the policy repo's own CI, so broken rules never reach the pod.

Troubleshooting is usually one of three things. No policy-check comment shows up at all: the server flag is not set, because the policies block on its own changes nothing. The check reports no policies, or errors instantly: path is a *server* filesystem path, so inside a container it has to match the mount point, never a path from the developer's repo. And approve_policies gets ignored: the commenter is not in owners. Check the exact username spelling, and for team-based owners check that the version control host really exposes team membership to Atlantis's token.

A gate anyone can open is not a gate
Advisory warn rules annotate the pull request and never block. Only deny failures gate the apply, so any rule that has to stop a change must be written as a deny. Then treat the exception path as attack surface. Keep owners down to a small security group. Set prevent_self_approve: true on every policy set so an author cannot wave through their own failure. Leave approval invalidation alone (keep sticky_policy_approvals off), because that is the thing stopping a blessed plan from being swapped for a worse one. Then prove it: open a test pull request that breaks a deny rule and watch atlantis apply refuse. A policy that only comments is a suggestion with a logo.

Everything that made this gate trustworthy lived in one file the pull request cannot touch: the server-side repo config. That file does far more than declare policies. It decides which repos may override workflows, which atlantis.yaml keys are honored at all, and how much rope each team gets. That control plane is next: *Server-side config & control*.

Advisory warnings that never graduate into denies teach people to scroll past them. Pick a small deny set that maps to incidents you have actually had: public buckets, security groups open to the world, unencrypted stores. Widen it only when the on-call crew agrees. And give every exception an expiry date. An exception that lives forever is policy debt wearing a smile.

Rules that match on resource type names alone miss the risk that hides in values. Write rules that read the fields that matter (acl, encryption flags) and that fail closed when a field is absent and absence means an unsafe default.

Try this

Run Conftest on your own machine against a terraform show -json plan that breaks a deny rule, then confirm the Atlantis policy_check stage fails the same way on a pull request. Same rules, same input, same verdict.

terminal
terraform plan -out=tfplan.bin && terraform show -json tfplan.bin > tfplan.json
conftest test tfplan.json -p policies/ --all-namespaces
# PR: atlantis plan && atlantis policy_check (or automatic stage)
output
FAIL - tfplan.json - main - Public S3 buckets are forbidden
# Atlantis: Policy Check Failed — apply comments ignored until fixed or excepted

Takeaway

Remember the crane keys. A policy check is Conftest and OPA reading the plan JSON, wired into the one server allowed to apply it, so a failed mandatory rule keeps the keys in the inspector's pocket.

Next: keep the line between warn and deny deliberate, make a named owner sign every exception on the pull request itself, and test your policies locally before they start gating anyone.

Quick check
01A rule in policies/s3.rego matches rc.change.after.acl == "public-read", but on this branch the acl comes from a variable Terraform can only settle during apply, so the plan JSON marks it "known after apply". What does the policy_check stage do, and what would you change?
Incorrect — Conftest only fails on what a rule actually matches. An attribute Terraform has not settled yet matches nothing, so the stage passes in silence.
Correct — The plan is the only evidence the gate ever sees, so a security field it cannot read should be treated as suspect rather than assumed harmless.
Incorrect — The stage sits between plan and apply and runs once. Nothing re-reads the resource afterwards, which is why runtime scanning still has a job.
Incorrect — Resolving computed values during apply is ordinary Terraform work. Unknown attributes in a plan are routine and give it no reason to stop.
02In policies/s3.rego the public-read check is written as deny contains msg and the cost-center tag check as warn contains msg. Your team decides a missing cost-center tag must now stop an apply. What is the smallest change that does that?
Incorrect — approve_count says how many owners must sign a failing set. Setting it to zero does not convert an advisory rule into a blocking one.
Incorrect — apply_requirements is a separate gate over pull request state such as approved, mergeable and undiverged. It takes states, not names of Rego rules.
Incorrect — Sign-off settings only decide who may excuse a failure. A warn never produces a failure to excuse, so raising the count changes nothing at all.
Correct — The rule head is the part Atlantis acts on. Same body, same match, but deny turns the result into a gate instead of a comment on the pull request.
03A deny failure blocks prod/s3, the security lead comments atlantis approve_policies, and Atlantis replies Approved Policies for 1 projects: prod/s3. Before anyone applies, the author pushes a commit that fixes a README typo. Where does that approval stand?
Incorrect — Autoplan fires on the push itself rather than on a diff of file types, so the policy check is redone and earlier sign-offs go with it.
Incorrect — An approval belongs to the exact plan an owner read. Binding it to the branch instead would let a blessed plan be swapped for a worse one.
Correct — Every plan re-arms the gate, so the verdict on the pull request always describes the plan that is about to run rather than an older one.
Incorrect — approve_count comes from the server-side repo config and does not move by itself. A new plan resets approvals to zero instead of raising the bar.

Related