atlantis.yaml & projects
Multi-directory, multi-workspace repos.
A big office building has a mailroom, and in that mailroom there is a wall of labelled pigeonholes. Hundreds of packages land every morning, and the wall decides which floor each one reaches. A Terraform monorepo (one Git repository holding the infrastructure code for many systems) is that building. It holds dozens of state roots: a VPC (virtual private cloud, your own fenced-off slice of a cloud provider's network) directory, a database directory, a folder per environment. Every pull request is a package that has to reach exactly the right ones. The file atlantis.yaml, sitting at the top of the repo, is your sorting wall. It declares projects, each one a directory plus a Terraform workspace carrying its own rules, and it tells Atlantis what to plan, where, and under what conditions.
Two words to pin down first. A *state root* is a directory you actually run terraform plan inside. It has a backend block (the setting that says where the recorded state of your infrastructure lives) and a state of its own. A *workspace* is Terraform's way of keeping several separately named states in one directory. Atlantis calls a directory plus a workspace a project, and everything else in this lesson hangs off that one idea.
What counts as a project
Point Atlantis at a repo with no atlantis.yaml and it guesses. The guessing has a name: autodiscovery. Any directory whose own Terraform files changed in the pull request becomes an implicit project, on the default workspace, with whatever settings the server hands out. (The server's autoplan file list defaults to .tf files, .tfvars files, and changes to the lockfile, the file that records the exact provider versions in use.) Guessing holds up for about a week. The moment you have shared modules, a folder per environment, or any rule about what applies before what, you write the file: version: 3 on the top line, the only version in use today, then a projects: list.
Under the hood a project is a three-part key: repository, directory, workspace. That key is stamped on everything. The plan file Atlantis saves in its data directory. The lock that stops a second pull request touching the same state. Each per-project section of the comment it writes back. The name field is a friendly alias for that key, so you can comment atlantis plan -p prod-vpc instead of atlantis plan -d live/prod/vpc -w default. The docs call name *mandatory* in exactly one corner: two projects sharing the same dir and the same workspace, which happens when a branch regex (a text pattern matched against branch names) splits one directory across projects. There is a practical second reason. When a single directory carries several workspaces, an unnamed project can only be targeted by spelling out -d and -w together, so most teams name every project and stop thinking about it.
$ tree -L 3 --dirsfirst.├── live│ ├── prod│ │ ├── vpc # state root: prod VPC│ │ └── database # state root: prod RDS│ └── staging│ ├── vpc│ └── database├── modules│ └── vpc # shared module — no backend, no state└── atlantis.yaml# 4 state roots = 4 Atlantis projects. modules/ is never a project;# changes to it must instead fan out to every project that uses it.
What a real atlantis.yaml looks like
Here is the file for the repo above, using the fields that pay for themselves in production.
version: 3 # required; 3 is the only current versionautomerge: falseparallel_plan: true # plan independent projects concurrentlyparallel_apply: false # apply serially — easier to reason aboutabort_on_execution_order_fail: trueprojects:- name: staging-vpcdir: live/staging/vpcworkspace: defaultterraform_version: v1.9.8 # Atlantis downloads and pins this binaryautoplan:enabled: truewhen_modified: # globs are relative to dir, NOT repo root- "**/*.tf"- ".terraform.lock.hcl"- "../../../modules/vpc/**/*.tf"execution_order_group: 0 # group 0 runs before group 1- name: prod-vpcdir: live/prod/vpcworkspace: defaultterraform_version: v1.9.8autoplan:enabled: truewhen_modified:- "**/*.tf"- ".terraform.lock.hcl"- "../../../modules/vpc/**/*.tf"execution_order_group: 1depends_on: [staging-vpc] # refuse apply until staging-vpc appliedplan_requirements: [undiverged]apply_requirements: [approved, mergeable, undiverged]workflow: prod # must be defined server-side (next lesson)# One dir, two workspaces → two projects; names beat -d/-w targeting- name: edge-usdir: live/edgeworkspace: us-east-1- name: edge-eudir: live/edgeworkspace: eu-west-1
Three things in there deserve a second look. First, when_modified globs (patterns like *.tf that match file paths) are read relative to the project's dir, not the top of the repo. That is why reaching the shared module takes ../../../modules/vpc/**/*.tf. Include that glob and a one-line edit to the module re-plans every project that consumes it, so the blast radius (how much breaks if the change is wrong) shows up in a single pull request. (How Atlantis compares changed files against these patterns belongs to the autoplan lesson.) Second, terraform_version pins the Terraform binary for that project alone. Atlantis downloads each version and caches it, which is how a half-migrated estate runs v1.6 and v1.9 side by side without a fight. Third, the requirement lists. approved means at least one reviewer other than the author has approved the pull request. mergeable means the Git host reports the pull request as mergeable, and on GitHub that verdict comes from branch protection rules. undiverged refuses to run if the base branch has moved since the most recent plan, and stale plans are the quiet killer of multi-team repos. plan_requirements gates planning itself, worth setting on prod folders because plan output on its own leaks resource names and network topology.
Now ordering. parallel_plan lets projects that do not depend on each other plan at the same time. With dozens of projects that is the difference between a 40-second pull request and a six-minute one. Applies stay serial here on purpose, because one apply at a time is far easier to follow when something goes sideways. execution_order_group sorts the groups by number, lowest first. depends_on flatly refuses an apply until the projects it names have applied, so staging genuinely gates prod instead of resting on someone remembering the order.
atlantis.yaml lives inside the repo, which means anyone who can open a pull request can propose changes to it, and Atlantis reads the copy of the file on the pull request branch, not the copy already merged into your main branch. Leave that unchecked and a pull request can water down the very apply_requirements it is supposed to satisfy, or define a custom workflow whose run step executes arbitrary shell commands with the server's cloud credentials. That is remote code execution (an outsider running commands on your machine) by pull request. So Atlantis splits the trust. The server-side repos.yaml decides which keys a repo is allowed to set (allowed_overrides) and whether repo-defined workflows are permitted at all (allow_custom_workflows, which defaults to false, and you want it to stay there). Treat atlantis.yaml the way you treat CI (continuous integration) config: gate it with CODEOWNERS, the file that tells your Git host who must review which paths, and let the server-side controls in the next lesson have the last word.One directory, several environments
When a single directory serves several environments through workspaces, write one project per workspace, the way edge-us and edge-eu do above. Atlantis runs terraform workspace select before every operation, so the state it touches is always the right one, and because locks are keyed on directory plus workspace, a pull request planning edge-us never blocks one planning edge-eu. Do not let workspaces become your default way of splitting environments, though. Separate directories give each environment its own backend, its own blast radius, and a diff a reviewer can actually read. Save workspace-per-project for deployments that really are symmetric, same code and a different region, where copying the directory would only invite drift.
What a multi-project pull request looks like
# You edit modules/vpc/main.tf and open a PR. The webhook fires and# Atlantis autoplans every project whose when_modified matched:Ran Plan for 2 projects:1. project: `staging-vpc` dir: `live/staging/vpc` workspace: `default`2. project: `prod-vpc` dir: `live/prod/vpc` workspace: `default`### 1. project: `staging-vpc` dir: `live/staging/vpc` workspace: `default`Plan: 1 to add, 2 to change, 0 to destroy.To apply this plan, comment: atlantis apply -p staging-vpc### 2. project: `prod-vpc` dir: `live/prod/vpc` workspace: `default`Plan: 1 to add, 2 to change, 0 to destroy.To apply this plan, comment: atlantis apply -p prod-vpc# Try to apply out of order and depends_on pushes back:you> atlantis apply -p prod-vpcatlantis> Can't apply your project unless you apply its dependencies: [staging-vpc]# So: staging first, verify, then prod.you> atlantis apply -p staging-vpcatlantis> Apply complete! Resources: 1 added, 2 changed, 0 destroyed.you> atlantis apply -p prod-vpcatlantis> Apply complete! Resources: 1 added, 2 changed, 0 destroyed.
Read what the transcript is telling you. The comment is written *per project*, and each section carries its own apply command. The dependency check fires at apply time, not at plan time. Every fresh push to the branch re-plans and supersedes the earlier output, and the server flag --hide-prev-plan-comments folds the superseded comments away so a long thread stays readable. None of the ordering depended on tribal knowledge. It was written down in the same file the pull request is reviewed against.
When the mapping goes wrong
you> atlantis planatlantis> Ran Plan for 0 projects:# Cause 1 — when_modified is relative to the project dir, not repo root:- when_modified: ["modules/vpc/**/*.tf"] # looks INSIDE live/prod/vpc/+ when_modified: ["../../../modules/vpc/**/*.tf"]# Cause 2 — declaring ANY project disables autodiscovery# (autodiscover.mode defaults to auto): a brand-new live/prod/dns# dir is invisible until someone adds its projects: entry.# Cause 3 — -p takes an exact name; regex targeting like# atlantis plan -p 'prod-.*'# only works if the server runs with --enable-regexp-cmd.
Ran Plan for 0 projects is the classic Atlantis failure, and nine times out of ten the mapping is at fault, not the webhook (the message your Git host sends Atlantis when something happens on a pull request). Work through it in order. Do the files this pull request changed match some project's when_modified, remembering the patterns resolve relative to dir? Did declaring projects switch autodiscovery off and leave a new Terraform directory unowned? Is a per-project branch regex filtering this pull request out? Only when all three come back clean do you go and read the server logs.
Habits that keep a big repo sane
A few habits keep this file honest at scale. Once the project count passes a few dozen, generate it. A short script that walks the repo and prints entries beats hand-editing, and a CI check that fails when a state root has no entry closes the autodiscovery blind spot at the same time. Put atlantis.yaml behind CODEOWNERS so a platform engineer reviews every change to it. Pin terraform_version on every project, because "whatever the server happens to have" is how version drift starts. Keep when_modified tight. One lazy **/* glob turns a typo in a README into a plan storm across forty projects. And keep the requirement lists strictest on prod, loose nowhere.
One field we skipped past on purpose: workflow: prod picks a named pipeline of steps, init, plan, plus any extra commands you bolt on, defined on the server rather than in the repo. What those workflows can do, why their run steps are at once the most powerful and the most dangerous feature in Atlantis, and how to write your own is exactly where we go next.
Monorepos drift into fifty untitled projects. Named projects make apply comments and metrics readable. Next time someone tells you Atlantis is noisy, check whether every pull request plans every stack because when_modified is **/*. Tighten the glob before you blame the tool.
Workspaces stack several states inside one directory. That is powerful, and it is easy to misread in a comment thread. Prefer separate directories for prod and staging when the blast radius differs. Use workspaces when the config really is identical apart from which state it writes to.
Treat a "Ran Plan for 0 projects" comment as a failed pipeline, not a quiet success. Either the pull request touched nothing that matches, or your when_modified globs are lying to you. Add a CI job that fails when Atlantis reports zero projects on a pull request that touches *.tf under a known state root. That one check catches months of silent misrouting.
Try this
Add a small atlantis.yaml to a repo with several directories, then open a pull request that touches one project and nothing else. Check that the comment lists that one project. Not zero, not all of them.
cat atlantis.yaml# version: 3# projects:# - name: app-prod# dir: infra/app# workspace: default# autoplan:# when_modified: ["*.tf", "../modules/**/*.tf"]atlantis plan -p app-prod
Ran Plan for project: app-prod dir: infra/app workspace: default# If you see "Ran Plan for 0 projects", when_modified or dir matching is wrong
Takeaway
atlantis.yaml is the sorting wall. Each project binds a directory, a workspace, and the rule for when to plan it. Wrong globs give you silent skips in one direction and plan spam in the other.
Name every project out loud, keep when_modified honest about the modules you share, and read a zero-project plan as a misconfiguration rather than a pass.
dir is live/prod/vpc. You want its autoplan to fire whenever the shared module sitting at modules/vpc/ at the top of the repo changes. Which when_modified entry does that?dir, so this one hunts for live/prod/vpc/modules/vpc, a path that was never created, and the pull request comes back with zero projects planned... walk you from live/prod/vpc back up to the top of the repo, which is what lets a one-line edit inside the shared module wake this project and show its blast radius in the same pull request.dir, so it fails the same way the unprefixed version does, and you get the same silent skip.atlantis.yaml with an explicit projects: list. A teammate adds a new state root at live/prod/dns, changes it in a pull request, and Atlantis plans nothing for that directory. What explains it?live/prod/dns entry, then add a CI check that fails when a state root has no entry, so the next omission is loud instead of silent.staging-vpc and prod-vpc planned, and prod-vpc is declared with depends_on: [staging-vpc]. You comment atlantis apply -p prod-vpc before applying staging. What comes back?parallel_plan is on, and it is the apply that has to queue behind staging.-p takes a literal project name, and prod-vpc is exactly that. The regex flag only enters the picture when you target with a pattern such as prod-.* instead.