Policy checks in the flow
Conftest/OPA gating applies.
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.
package main# keeps this parseable on any Conftest — the modern deny/warn syntax# below is only the DEFAULT dialect since Conftest 0.60import rego.v1# mandatory: a matching deny blocks `atlantis apply`deny contains msg if {some rc in input.resource_changesrc.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 blockswarn contains msg if {some rc in input.resource_changesrc.change.after.tags != nullnot 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.
# Atlantis's policy_check stage is essentially three commands —# reproduce any PR failure at your desk before blaming the server.terraform init -input=falseterraform plan -out tfplan.binaryterraform show -json tfplan.binary > tfplan.json # what $SHOWFILE holdsconftest 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.
# 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'sapprove_count: 1 # approvals needed per failing policy setowners:users: [security-lead] # the only accounts approve_policies obeyspolicy_sets:- name: security-baselinepath: /policies # directory of .rego files on the SERVERsource: localprevent_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:
you git push → autoplan firesatlantis 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 allowed1 test, 0 passed, 0 warnings, 1 failure, 0 exceptions* To approve failing policies an authorized approver cancomment: `atlantis approve_policies`* Or, address the policy failure and re-plan.you atlantis applyatlantis Apply Failed: All policies must pass for projectbefore running apply.sec-lead atlantis approve_policiesatlantis Approved Policies for 1 projects: prod/s3you atlantis applyatlantis 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.
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:
orgAllowlist: github.com/acme/*environment:ATLANTIS_ENABLE_POLICY_CHECKS: "true"# mount the Rego bundle where repoConfig's `path` expects itextraVolumes:- name: policiesconfigMap:name: atlantis-policiesextraVolumeMounts:- name: policiesmountPath: /policiesreadOnly: truerepoConfig: |---repos:- id: github.com/acme/*policies:approve_count: 1owners:users: [security-lead]policy_sets:- name: security-baselinepath: /policiessource: localprevent_self_approve: true
helm repo add runatlantis https://runatlantis.github.io/helm-chartskubectl create namespace atlantiskubectl -n atlantis create configmap atlantis-policies --from-file=policies/# configmap/atlantis-policies createdhelm 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.
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.
terraform plan -out=tfplan.bin && terraform show -json tfplan.bin > tfplan.jsonconftest test tfplan.json -p policies/ --all-namespaces# PR: atlantis plan && atlantis policy_check (or automatic stage)
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.
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?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?