Server-side config & control
Who can define workflows.
A hotel lets every guest nudge the thermostat in their own room. Facilities sets the hard limits from a locked control room, so nobody gets a room to 35°C no matter which buttons they mash. Atlantis splits the same way. The repo-side atlantis.yaml is the thermostat: handy, sitting right next to the code, editable by anyone who can open a PR (pull request, a proposed change to the code that someone reviews before it lands). The server-side repos.yaml is the control room. Only the operator who runs the Atlantis server can read or write it, and it decides which knobs the thermostat even has.
That split is where the security of the whole system lives. Atlantis holds credentials strong enough to rebuild your infrastructure, and it takes its orders from pull requests. Pull requests are the one thing in your pipeline that contractors, brand-new hires and (on a public repo) complete strangers can create whenever they feel like it. Server-side config is what stops one of those pull requests from rewriting the rules it is supposed to pass.
Two files, one trust boundary
Get the two files straight before anything else. atlantis.yaml sits at the root of a repo and describes *projects*: which folders hold Terraform code, which workspaces exist, when to plan automatically. repos.yaml lives with the server and answers a completely different question. What is this repo *allowed* to configure? (That filename is only a convention. You point the server at the file with --repo-config.) Three keys carry most of the weight. apply_requirements lists what a pull request must satisfy before atlantis apply will run: approved means a reviewer signed off, mergeable means no conflicts and the required checks are green, undiverged means the branch is not sitting behind the branch it targets. allowed_overrides is a whitelist, one key at a time, of the settings a repo's own atlantis.yaml may change. allow_custom_workflows decides whether a repo may write workflow steps of its own, including run steps, which are plain shell commands.
# Server-side config — lives with the server; only operators can edit it.repos:# Baseline for every repo. Order matters: for each key the LAST# matching entry wins, so the wildcard goes first, exceptions after.- id: /.*/branch: /.*/apply_requirements: [approved, mergeable, undiverged]allowed_overrides: [workflow] # repos may PICK a workflow...allowed_workflows: [default, govcloud] # ...from this menu onlyallow_custom_workflows: false # ...and may NOT author their ownworkflow: default# Exception: the platform team's own repo. Higher trust, but with a# compensating control — even planning waits for an approving review.- id: github.com/acme/platform-infraallow_custom_workflows: trueplan_requirements: [approved]# Workflows defined here are operator-reviewed code, not PR content.workflows:govcloud:plan:steps:- init- plan:extra_args: ["-var-file=gov.tfvars"]apply:steps: [apply]
How the server resolves config
The matching rules deserve a slow read, because one small ordering mistake quietly loosens every guardrail you thought you had. Each entry's id is either an exact repo path (github.com/acme/payments) or a regex wrapped in slashes (/.*/). A regex, short for regular expression, is a pattern that matches many strings at once. When a webhook arrives (a webhook is the little message your Git host fires off the moment something happens on a pull request), Atlantis walks the repos list from top to bottom and, for every key, the last matching entry wins. It works like the notes on a shared fridge: the one stuck on top is the one everybody obeys. That is why the wildcard baseline goes first and the exceptions come after. Flip the order and your restrictive baseline flattens the exception. Worse, a broad regex parked at the bottom silently overrides every hardened entry above it.
Once the server has settled on an entry, it reads the repo's atlantis.yaml and filters it. Any key that is not listed in allowed_overrides gets rejected with an error rather than dropped in silence, so the plan fails loudly on the pull request itself. allowed_workflows gives you a useful middle setting: a repo can *pick* from a menu of workflows the operator wrote, without being able to author steps of its own. Then there is plan_requirements, the younger sibling of apply_requirements. It gates planning itself, which matters more than it first sounds. The init step of a plan already downloads whatever provider binaries and modules the pull request's own .tf files name, and the plan that follows runs those providers.
Deploying it: Helm, flags, and restarts
On a plain VM (virtual machine, a single server you run yourself) you hand atlantis server the flag --repo-config /etc/atlantis/repos.yaml, or set ATLANTIS_REPO_CONFIG in the environment. On Kubernetes, the official Helm chart does the plumbing for you (Helm is the package manager for Kubernetes): whatever you put in the repoConfig value gets written to a mounted file and wired to that flag. The chart runs Atlantis as a StatefulSet, a pod with its own name and its own disk that survive a restart, because the server keeps plans and locks on that disk between webhook deliveries. Two neighbouring controls belong in the same values file. One is the webhook secret, a shared password that lets Atlantis prove a payload really came from your Git host and not from someone who stumbled onto the endpoint. The other is basic auth for the web UI (user interface, the page you open in a browser) via --web-basic-auth, because that page can throw away locks. The hardening lesson goes further on both.
helm repo add runatlantis https://runatlantis.github.io/helm-chartshelm upgrade --install atlantis runatlantis/atlantis \--namespace atlantis --create-namespace -f values.yaml# Release "atlantis" does not exist. Installing it now.# NAME: atlantis# NAMESPACE: atlantis# STATUS: deployed# REVISION: 1# values.yaml — the chart mounts repoConfig as a file and passes --repo-config:# orgAllowlist: github.com/acme/*# github:# user: atlantis-bot# token: <VCS token — from a Secret in production># secret: <webhook secret — authenticates payloads from GitHub># repoConfig: |# ---# repos:# - id: /.*/# apply_requirements: [approved, mergeable, undiverged]# allowed_overrides: [workflow]# allow_custom_workflows: falsekubectl -n atlantis get statefulset atlantis# NAME READY AGE# atlantis 1/1 2m14skubectl -n atlantis logs atlantis-0 | tail -n 1# {"level":"info","msg":"Atlantis started - listening on port 4141"}
One operational detail bites nearly everyone exactly once. repos.yaml is read at startup and never again. Editing the file, or the Helm value, changes nothing until the pod restarts. Bake a rollout restart into whatever process ships your config changes.
Watching the boundary hold on a pull request
Here is what enforcement looks like from the pull request side. An author whose repo is covered only by the wildcard entry tries two escalations. First they weaken the gate inside atlantis.yaml. Then they try to apply before anyone reviews the change. Both attempts fail with a message that says exactly why, and that is what you want. Loud failures teach a team where the boundary sits.
# The PR adds this to atlantis.yaml, trying to drop the approval gate:## version: 3# projects:# - dir: prod/vpc# apply_requirements: []## Atlantis replies on the PR — no plan runs at all:**Plan Error**parsing atlantis.yaml: repo config not allowed to set 'apply_requirements'key: server-side config needs 'allowed_overrides: [apply_requirements]'# The author reverts the override; plan succeeds. Then, before any review:> atlantis applyRan Apply for dir: `prod/vpc` workspace: `default`**Apply Failed**: Pull request must be approved according to the project'sapproval rules before running apply.# Only after an approving review (and a green mergeability check)# does `atlantis apply` actually run terraform apply.
run step is plain shell, and it executes as the Atlantis server, holding the server's cloud credentials, the moment a pull request triggers a plan. No approval. No merge. No apply. That is a full infrastructure compromise delivered through a pull request. The exposure also starts earlier than most teams assume: planning already fetches whatever providers and modules the pull request's .tf files name and then runs those providers, and Terraform's external data source can shell out during a plain terraform plan. So treat *plan* as code execution too. Keep allow_custom_workflows: false by default. Enforce apply_requirements on the server so a repo cannot approve itself. Grant allowed_overrides one key at a time. Reserve custom-workflow rights for repos owned by the team that operates Atlantis. The server-side file is the real security control, and the repo-side file is a convenience that lives inside its limits.Troubleshooting and defaults that hold
When someone tells you the server-side config "doesn't work", it is almost always one of three things. A stale pod, because nobody restarted it after the change. A YAML type error that stopped the server from booting at all. Or an id written as .* instead of /.*/, which without the slashes is read as an exact repo name and therefore matches nothing.
# "My repos.yaml change did nothing" — three usual suspects:# 1. Stale pod: the file is parsed ONCE, at boot. Restart after every change.kubectl -n atlantis rollout restart statefulset atlantis# 2. Boot failure: a YAML type error stops the server entirely — check logs.kubectl -n atlantis logs atlantis-0 --previous | tail -n 2# Error: initializing server: parsing /etc/atlantis/repos.yaml:# yaml: unmarshal errors: line 5: cannot unmarshal !!str into []string# 3. Wrong id syntax: regexes need slashes; anything else is an exact match.# id: /.*/ -> regex, matches every repo# id: github.com/acme/payments -> exact match only# id: .* -> exact repo literally named ".*" (never matches)
Know where this model stops, as well. repos.yaml is global and static. There is no per-team delegation short of listing repos one by one, no live lookup against your identity provider, and branch regexes (matched against the branch a pull request targets) are the only granularity you get below the level of a whole repo. The trade-off is real: a strict baseline means a platform-team pull request for every workflow tweak, and teams feel that friction. allowed_workflows menus are how you buy some of that autonomy back without handing out run steps. For defaults that hold up, enforce approved, mergeable and undiverged everywhere, keep allowed_overrides at [workflow] or empty, keep allow_custom_workflows: false, and treat every loosening as a security review rather than a config change.
Server-side config decides *what code* the automation will run on a repo's behalf. The other half of the blast radius is *what power* that code runs with, meaning the cloud credentials sitting inside the Atlantis pod. That is the next lesson: scoping them, storing them and rotating them.
Open-source repos and contractor-heavy ones make this boundary non-negotiable. Anyone who can open a pull request can propose YAML. If that YAML can rewrite run steps, they can walk out with the cloud credentials Atlantis holds. Server-side workflows close that door, and reviewing atlantis.yaml turns back into a question about project routing rather than about privilege escalation.
Write the split down for contributors in one sentence they will actually remember: you may add projects and when_modified, you may not add run steps. Put those same words in CODEOWNERS, the file that says which reviewers a given path requires, so a stray workflow key gets caught during review.
Dump the effective server config into your private ops repo with secrets redacted, so an auditor can read the allowlists, the apply requirements and the workflow names without anyone opening an SSH (secure shell, a remote terminal) session. Drift between the pod that is running and the last values file anyone merged is a recurring incident class. Make the config you intend reviewable.
If a trusted team genuinely needs repo-level workflow overrides, allowlist the specific keys and keep run off the list. Overrides for dir and when_modified usually cover what they actually wanted. The moment run becomes overridable from a pull request branch, you are back to a stranger executing code with your cloud credentials.
Try this
Put the boundary in front of your own eyes. Read what the server says about allow_custom_workflows, the workflow definitions and plan_requirements, then open a pull request that tries to add a workflow of its own. Prove to yourself that a pull request cannot quietly swap out the plan pipeline.
grep -n "allow_custom_workflows\|workflow\|plan_requirements" /etc/atlantis/repos.yaml# attempt a PR that adds a custom workflow with curl|bash — expect server to ignore or reject
allow_custom_workflows: false# PR-defined workflow not used# server workflow "default" steps remain init, plan, conftest
Takeaway
repos.yaml is the control room and atlantis.yaml is the thermostat. A pull request author never gets to redefine the pipeline that guards a production apply.
Next: keep custom workflows switched off unless you would trust the contributor with your cloud account, and keep the apply requirements (approvals, branch protection) in the server config where a pull request cannot reach them.
github.com/acme/platform-infra near the top of repos.yaml, then append a broad /.*/ baseline at the very bottom of the same list. What does platform-infra end up running under?allow_custom_workflows: true with plan_requirements: [approved], so even a plan waits for a reviewer. What risk is that gate covering?allowed_overrides: [workflow]) opens a pull request whose atlantis.yaml sets apply_requirements: [] on prod/vpc. Atlantis answers with a Plan Error naming that key. Where does that leave the pull request?