# Generating Atlantis config from Terramate stacks

> Atlantis learns which Terramate stacks exist, and how to plan each, from the stacks themselves.

Engineering note by Oleksandr Ponomarov · 10 November 2026 · Topics: terraform, terramate, aws, atlantis
Canonical: https://oponomarov.com/blog/posts/generating-atlantis-config-from-terramate-stacks/

A [Terramate](https://terramate.io/docs/cli/) repository describes its
infrastructure as a tree of small, independently planned units, and
[Atlantis](https://www.runatlantis.io/) is a natural partner for it: every
pull request gets a plan, and every apply happens in the open. But Atlantis
does not read Terramate's tree. It reads its own list of projects, kept in
`atlantis.yaml`, and the moment that list exists it becomes a second copy of
the tree that someone has to keep in step with the first.

This post introduces
[terramate-atlantis-config](https://github.com/devops-roast/terramate-atlantis-config),
a small Go program that derives that list from the stacks on every pull
request, and walks through the features you are likely to reach for first:
generating the list, naming projects, carrying per-stack settings as
globals, narrowing the list, and deciding where the file is generated. It
assumes some familiarity with Terramate stacks and globals; if they are new
to you, the [earlier post on structuring a Terramate monorepo](/blog/posts/structuring-a-terraform-monorepo-with-terramate/)
is a better starting point.

> [!NOTE] Series and demo
>
> First of two posts on Terramate and Atlantis. Every example comes from
> [terramate-atlantis-demo](https://github.com/shmileee/terramate-atlantis-demo),
> which pins its tool versions in
> [`mise.toml`](https://github.com/shmileee/terramate-atlantis-demo/blob/d9edc645f003ed315431e514e72e77e2ce26293f/mise.toml)
> and its Atlantis server in
> [`atlantis/Dockerfile`](https://github.com/shmileee/terramate-atlantis-demo/blob/d9edc645f003ed315431e514e72e77e2ce26293f/atlantis/Dockerfile).
> Transcripts were run with those pins.

## A few terms

Both tools have a name for the same idea, so it helps to pin the vocabulary
down before going further.

A _stack_ is Terramate's unit: one directory with a `stack.tm.hcl` file, one
Terraform root module, one state. A _project_ is Atlantis' unit: a directory
(and workspace) that Atlantis plans and applies as one thing. Throughout this
post a stack becomes at most one project, so the two words describe the
same directory seen from either side.

Atlantis reads two kinds of configuration. The _repo-level config_ is the
`atlantis.yaml` at the root of the repository, which lists the projects and
their per-project settings. The _server-side repo config_, usually called
`repos.yaml`, lives on the Atlantis server and decides what a repository is
allowed to set in its own `atlantis.yaml`. A _pre-workflow hook_ is a command
the server-side config tells Atlantis to run in its checkout of a pull request
before it reads the repo-level config.

Finally, a project _autoplans_ when a pull request modifies a file matching
one of its `when_modified` patterns, which are relative to the project's
directory.

Most of what follows is a translation between the two vocabularies, and the
table below is a map of it. The last two rows belong to the [next
post](/blog/posts/planning-terramate-stacks-in-atlantis-without-generated-code/),
but they are listed here so the shape of the whole is visible:

| Terramate has | Atlantis needs | Covered in |
| --- | --- | --- |
| a stack directory | a project with a `dir` | this post |
| the stack's path | a unique project `name` | this post |
| `atlantis_*` globals | per-project settings | this post |
| `after` and `before` | `depends_on` and order groups | the next post |
| files a stack reads | `when_modified` patterns | the next post |

## Why Atlantis misses a new stack

Atlantis can run without any `atlantis.yaml` at all. In that mode it
discovers projects on its own, by looking at which directories a pull
request touched. Most Terramate repositories outgrow this quickly, because
discovery gives every project the same settings: there is no way to say that
production stacks run a stricter workflow, or need more approvals before
apply, than development ones. Those settings live in `atlantis.yaml`, and so
an `atlantis.yaml` appears.

The catch is in how Atlantis treats discovery once that file exists. Its
[repo-level config documentation](https://www.runatlantis.io/docs/repo-level-atlantis-yaml.html)
puts it in one line: in the default `auto` mode, "projects will be discovered
only if the repo has no `projects` configured." List one project and
discovery switches off for all of them.

The failure that follows is a quiet one. A pull request adds a new stack. It
is not in the list, so Atlantis plans nothing and says nothing about it; the
pull request looks clean, it merges, and the stack never reaches the cloud.
Nothing failed, which is exactly why nobody notices.

There are two obvious fixes, and both are reasonable. The first is
discipline: whoever adds a stack also adds its project, and reviewers check.
That works, right up until the first time it doesn't, and the cost of that
one time is an unapplied stack. The second is to set `autodiscover.mode` to
`enabled`, which makes Atlantis discover unlisted directories even when
projects are configured. That closes the gap, but per-project settings
belong to entries in the list, and a discovered project has no entry. A new
production stack would be planned and applied under whatever the server's
defaults happen to be.

Set side by side, the options differ less in whether a new stack gets
planned than in where its settings come from:

| Approach | New stack planned? | Its settings come from |
| --- | --- | --- |
| List projects by hand | only once listed | its list entry, kept by reviewers |
| `autodiscover.mode: enabled` | yes | the server's defaults |
| Generate the list | yes | the stack's own globals |

What the first two have in common is that the list is still maintained
beside the tree rather than derived from it. That suggests a rule of thumb:
when one file describes another, generate it. This is the same reasoning
behind
[terragrunt-atlantis-config](https://github.com/transcend-io/terragrunt-atlantis-config),
which does this job for Terragrunt and which the Atlantis
[pre-workflow hook documentation](https://www.runatlantis.io/docs/pre-workflow-hooks.html)
uses as its own example. terramate-atlantis-config applies the idea to
Terramate stacks, their globals and their ordering.

## The demo repository

It's easier to discuss this with something concrete, so the rest of this
post uses the demo repository, which is wired to a real Atlantis server. Its
stacks hold only `terraform_data` resources, so they plan without any cloud
credentials. Here is its layout, abridged to the files that matter:

```text
.terramate-atlantis.yaml
atlantis/repos.yaml
stacks/
├── aws/
│   ├── aws.tm.hcl
│   ├── acme-development/
│   │   ├── account.tm.hcl
│   │   └── eu-west-1/
│   │       ├── app/
│   │       └── network/
│   └── acme-production/
│       ├── account.tm.hcl
│       └── eu-west-1/
│           ├── app/
│           ├── manual/
│           ├── network/
│           └── skipped/
└── imports/
    └── context.tm.hcl
```

There are two accounts, each with an `account.tm.hcl` holding the globals
for every stack below it. Production has two extra stacks, `manual` and
`skipped`, whose names hint at what they will demonstrate. Each scenario
below has a matching
[pull request](https://github.com/shmileee/terramate-atlantis-demo/pulls)
showing Atlantis' actual response.

## Generating the project list

The tool has one main command. Run from the repository root, it finds every
stack and writes one project per stack:

```text
$ terramate-atlantis-config generate
Generated atlantis.yaml with 5 projects
```

It reads stack definitions and globals through the Terramate SDK, so the
`terramate` CLI is not needed to run it, and it changes nothing except the
file it writes. The output path comes from `.terramate-atlantis.yaml`, the
tool's own config file, which in the demo holds just two settings:

```yaml title=".terramate-atlantis.yaml"
output: atlantis.yaml
apply_requirements: [mergeable]
```

The same settings can be passed as flags, such as
`--apply-requirements mergeable`, and a flag overrides the file. The file is
the better home for anything that should hold for every run, because the
same settings then apply on a laptop, in CI and on the Atlantis server
without anyone repeating them.

The second line is a default for every project: a pull request has to be
mergeable before Atlantis will apply it. Here is the first of the generated
projects:

```yaml
# Generated by terramate-atlantis-config. DO NOT EDIT.
# Re-generate with: terramate-atlantis-config generate
version: 3
automerge: false
parallel_plan: true
parallel_apply: true
projects:
  - name: acme-development-eu-west-1-app
    dir: stacks/aws/acme-development/eu-west-1/app
    apply_requirements:
      - mergeable
    autoplan:
      enabled: true
      when_modified:
        - '*.tf'
        - '*.tf.json'
        - .terraform.lock.hcl
# ... four more projects ...
```

Here we have a project per stack, with its `dir` set to the stack's path and
autoplan watching the stack's Terraform files. The repository has six stacks
but only five projects; the missing one is `skipped`, for reasons we'll come
to shortly. Before that, the project name deserves a closer look, because it
is the one part of this file that people end up typing.

## How projects are named

An Atlantis project's name is optional, but it must be unique across the
repository, and names are what you pass to `atlantis plan -p` when you want
one project rather than all of them. The tool derives the name from the
stack's path, and it has four strategies for doing so. Running each against
the demo gives these names for the development `app` stack:

| Strategy | Name of the development `app` stack |
| --- | --- |
| `auto-strip` (default) | `acme-development-eu-west-1-app` |
| `stack-name` | `aws-acme-development-eu-west-1-app` |
| `last-n`, depth 2 | `acme-development-eu-west-1-app` |
| `full` | `stacks-aws-acme-development-eu-west-1-app` |

`auto-strip` removes the path segments that every stack shares, here
`stacks/aws/`, since they carry no information. `full` keeps them.
`stack-name` uses the stack's `name` attribute and up to three parent
directories.

The `last-n` row is the interesting one. With a depth of two, both `app`
stacks would be called `eu-west-1-app`, so the tool adds parent segments to
each until they differ, and the result is the same as `auto-strip`. The
`manual` stack exists in only one account, so nothing collides and it
keeps the short name, `eu-west-1-manual`. A strategy is a starting point;
uniqueness always wins.

For an introduction the default is the right choice, and the
[project naming documentation](https://github.com/devops-roast/terramate-atlantis-config/blob/80324c4dbdb4be354760c6bfa12444d684ae9877/docs/project-naming.md)
covers the rest. There is one property of `auto-strip` that matters later,
though: because the shared prefix depends on every stack, adding a stack in
a new place can change it. We'll return to that under the limits below.

## Per-stack settings as globals

Generating the list solves the missing-project problem, but on its own it
would just reproduce what discovery already did. The more useful part is
that per-project settings come from the stacks too, as Terramate
[globals](https://terramate.io/docs/cli/code-generation/variables/globals)
whose names start with `atlantis_`. The tool knows seven of them:

| Global | Type | Effect on the project |
| --- | --- | --- |
| `atlantis_skip` | bool | no project at all |
| `atlantis_workflow` | string | sets `workflow` |
| `atlantis_apply_requirements` | list | sets `apply_requirements` |
| `atlantis_autoplan` | bool | turns autoplan on or off |
| `atlantis_terraform_version` | string | sets `terraform_version` |
| `atlantis_extra_deps` | list | adds `when_modified` entries |
| `atlantis_when_modified` | list | replaces `when_modified` |

Globals are inherited: a global set in a directory applies to every stack
below it. That makes the account directory the natural place for settings
that differ by environment. The production account sets two:

```hcl title="stacks/aws/acme-production/account.tm.hcl"
globals {
  environment  = "production"
  account_name = "acme-production"

  # Every production stack uses the server's "production" workflow
  # and needs more checks before apply.
  atlantis_workflow           = "production"
  atlantis_apply_requirements = ["mergeable", "undiverged"]
}
```

Every production project picks both up, while the development projects keep
the repository default:

```yaml
  - name: acme-production-eu-west-1-network
    dir: stacks/aws/acme-production/eu-west-1/network
    workflow: production
    apply_requirements:
      - mergeable
      - undiverged
    # ... autoplan, as before ...
```

`undiverged` additionally blocks an apply if `main` has changed since the
last plan; it needs the `merge` checkout strategy, which the demo's Compose
file sets. The `production` workflow itself is not defined in
`atlantis.yaml`. It lives in the server-side config, the demo's
`atlantis/repos.yaml`, and adds one step before the plan:

```yaml title="atlantis/repos.yaml (excerpt)"
workflows:
  production:
    plan:
      steps:
        - init
        - run: terraform validate -no-color
        - plan
```

The two remaining stacks each set a single global of their own. `skipped`
sets `atlantis_skip = true`, which leaves it out of the generated file
entirely, and `manual` sets `atlantis_autoplan = false`, which keeps it in
the file but stops Atlantis from planning it until someone asks. Putting
the account's globals and the stacks' own together, the six stacks come
out like this:

| Stack | `workflow` | `apply_requirements` |
| --- | --- | --- |
| development `app`, `network` | server default | `mergeable` |
| production `app`, `network` | `production` | `mergeable`, `undiverged` |
| production `manual`, autoplan off | `production` | `mergeable`, `undiverged` |
| production `skipped` | no project | no project |

Nothing in that table was written per stack except the last two rows, and
those were written in the stacks they describe. A new production stack
lands in the second row without anyone deciding that it should.

### What the server must allow

There is one trap here worth knowing about in advance. Atlantis only accepts
an `atlantis.yaml` whose keys the server-side config allows, and if a single
project breaks that rule it rejects the _whole_ file, not just that
project. A file generated with the tool's defaults uses none of the
restricted keys, but the settings above do:

| The generated file has | Because of | The server must allow |
| --- | --- | --- |
| a project `workflow` | `atlantis_workflow` | `workflow` in `allowed_overrides` |
| project `apply_requirements` | the global, or a default in the config file | `apply_requirements` in `allowed_overrides` |
| a top-level `workflows` block | generated workflows | `allow_custom_workflows`, set to `true` |

The demo uses the first two, so its server-side config allows both:

```yaml title="atlantis/repos.yaml (excerpt)"
repos:
  - id: /.*/
    pre_workflow_hooks:
      - run: terramate-atlantis-config generate --output atlantis.yaml
        description: terramate-atlantis-config
    allowed_overrides:
      - workflow
      - apply_requirements
```

The [troubleshooting guide](https://github.com/devops-roast/terramate-atlantis-config/blob/80324c4dbdb4be354760c6bfa12444d684ae9877/docs/troubleshooting.md)
lists the messages Atlantis prints when a key is missing, such as
`repo config not allowed to set 'workflow' key`. The practical consequence
is an ordering rule for rollouts: allow the key on the server first, and
only then add the global that produces it.

### Misspelled and mistyped globals

A misspelled global fails softly. The tool reads only the globals it knows
and warns about any other `atlantis_*` name. Renaming `atlantis_workflow`
to `atlantis_workflows` in the production account gives:

```text
Warning: unknown global "atlantis_workflows" (stacks: stacks/aws/acme-production/eu-west-1/app, stacks/aws/acme-production/eu-west-1/manual, stacks/aws/acme-production/eu-west-1/network and 1 more) is ignored; known atlantis globals: atlantis_apply_requirements, atlantis_autoplan, atlantis_extra_deps, atlantis_skip, atlantis_terraform_version, atlantis_when_modified, atlantis_workflow
```

The run still succeeds, and the production projects quietly lose their
workflow, so the warning deserves a place in CI logs where someone will see
it. A global of the wrong _type_ is treated differently. Setting
`atlantis_autoplan = "no"` in the `manual` stack fails the run rather than
guessing what was meant:

```text
Error: discovering stacks: extracting atlantis globals for stack "/stacks/aws/acme-production/eu-west-1/manual": atlantis_autoplan must be bool
```

The difference is deliberate, as far as I can tell: an unknown name might
belong to some other tool's convention, while a known name with the wrong
type can only be a mistake.

## Generating only part of the list

So far every stack has become a project. Most of the time that is what you
want, but there are occasions to narrow the list: a repository shared by
two Atlantis servers, say, or a directory of stacks that some other system
applies. The tool has filters by path and by Terramate tag, and each has an
include and an exclude form. Run against the demo, the path filters give:

| Flags | Projects generated |
| --- | --- |
| none | all five |
| `--filter-path stacks/aws/acme-production` | the three production projects |
| `--exclude-path '**/manual'` | four: all but `manual` |
| `--filter-path '**/network'` | the two `network` projects |

A pattern selects a directory and everything nested under it, `*` matches
within one path segment and `**` across any number of them. The tag forms,
`--filter` and `--exclude-tags`, take the same syntax as `terramate run
--tags` and match the `tags` attribute of a stack; the demo's stacks have no
tags, so they are not shown here.

Two properties of filters are easy to miss. A filter never renames a
project, because names are always computed against every stack that isn't
skipped, so a project called `acme-production-eu-west-1-app` keeps that name
whether or not a given run includes it. And `atlantis_skip` is applied after
the filters, so a skipped stack stays skipped whatever the flags say.

There is also a third kind of filter, `--changed`, which keeps only the
stacks Terramate considers changed against a Git ref. It sounds like the
obvious thing to run on a pull request, and the next post explains why it is
usually not.

## Where the file is generated

The `repos.yaml` above also answers a question the tool leaves open: where
`atlantis.yaml` gets generated. There are two reasonable answers, and the
choice is a tradeoff rather than a best practice.

The first is to generate the file locally and commit it. A
[pre-commit](https://pre-commit.com/) hook regenerates it on every commit,
and CI fails the build when the committed copy is stale. `--check` makes
that decision with an exit code, and `--diff` shows why. Adding a `dns`
stack to the development account without regenerating gives:

```text
$ terramate-atlantis-config generate --check --diff
--- atlantis.yaml
+++ atlantis.yaml
@@ -7,6 +7,16 @@
 projects:
   - name: acme-development-eu-west-1-app
     dir: stacks/aws/acme-development/eu-west-1/app
+    apply_requirements:
+      - mergeable
+    autoplan:
+      enabled: true
+      when_modified:
+        - '*.tf'
+        - '*.tf.json'
+        - .terraform.lock.hcl
+  - name: acme-development-eu-west-1-dns
+    dir: stacks/aws/acme-development/eu-west-1/dns
     apply_requirements:
       - mergeable
     autoplan:
atlantis.yaml is out of date, run the same command without --check and --diff to update it
```

The diff is a little awkward to read, because projects are sorted by
directory and the new one slots in after `app`, but the exit code of `1` is
what CI acts on. A companion command, `validate`, checks an existing file
for the problems that would make Atlantis reject it, such as duplicate
project names or a `depends_on` entry that names no project:

```text
$ terramate-atlantis-config validate atlantis.yaml
atlantis.yaml is valid
```

Committing keeps every change to the project list visible in review, which
is valuable when the list is short and changes rarely. Its cost is that
every new stack, rename, or settings change produces a diff in a generated
file that reviewers mostly skim.

The second is to let Atlantis generate it, with the pre-workflow hook shown
earlier. Atlantis clones the pull request, runs the hook, and only then
reads `atlantis.yaml`, so the file exists only in Atlantis' checkout and
never in Git:

<figure class="concept-diagram" data-concept-diagram>
  <div class="diagram-exhibit" data-exhibit role="img"
       aria-label="First, in Atlantis' clone of the pull request, the pre-workflow hook writes atlantis.yaml. Then Atlantis reads it and autoplans the projects whose patterns match.">
    <div class="diagram-exhibit-label">FIRST · THE HOOK WRITES</div>
    <div>
      <span>atlantis.yaml</span>
    </div>
    <div class="diagram-exhibit-label diagram-exhibit-label-secondary">THEN · ATLANTIS</div>
    <div>
      <span>reads it</span><i aria-hidden="true">→</i>
      <span>autoplans</span>
    </div>
  </div>
  <figcaption class="exhibit-caption">The hook writes the file into Atlantis' clone of the pull request before Atlantis reads any repo-level config, which is what lets the file be generated rather than committed.</figcaption>
</figure>

This needs the binary in the Atlantis image (the demo's Dockerfile installs
a checksummed release), and it means nobody reviews the generated file at
all. It also adds a failure mode that committing does not have.

> [!WARNING] A failing hook does not stop Atlantis
>
> The Atlantis
> [pre-workflow hook documentation](https://www.runatlantis.io/docs/pre-workflow-hooks.html)
> says that by default hooks "do not prevent Atlantis from executing its
> workflows", `plan` and `apply`, "even if a `run` command exits with an
> error."
> When a hook writes the file, set `ATLANTIS_FAIL_ON_PRE_WORKFLOW_HOOK_ERROR`
> to `true`, as the demo's Compose file does, so a broken generation step
> stops the run instead of letting Atlantis carry on without the file.

Laid out side by side, the two setups trade review for noise:

| What changes | Commit the file | Generate it in Atlantis |
| --- | --- | --- |
| Runs the tool | pre-commit hook, CI `--check` | a pre-workflow hook |
| The file in review | every change, as a diff | never; only its inputs |
| The Atlantis image needs | nothing extra | the tool's binary |
| A stale file is caught by | `--check` in CI | can't be stale |
| Tool warnings appear in | the committer's terminal, CI | the hook's job page in Atlantis |

The last row is worth a word. Atlantis does not post a hook's output as a
comment. It shows it on a job page on the Atlantis server, linked from the
hook's `atlantis/pre_workflow_hook` status on the pull request, so a warning
such as the misspelled global above is seen only by someone who clicks
through. With the file generated on the server, it's worth also
running the tool in CI, if only to put its warnings in front of the person
who caused them.

The demo takes the second path, because the project list is fully
determined by files that _are_ reviewed. If you would want to read the
file, commit it; if it would only ever be rubber-stamped, generate it.

## What the pull requests show

Each of the demo's three pull requests isolates one behavior:

| Pull request | Changes | Atlantis plans |
| --- | --- | --- |
| [#1](https://github.com/shmileee/terramate-atlantis-demo/pull/1) | adds a development stack, `new-stack` | the new stack |
| [#2](https://github.com/shmileee/terramate-atlantis-demo/pull/2) | `network` in both accounts | both, production with `validate` |
| [#3](https://github.com/shmileee/terramate-atlantis-demo/pull/3) | `skipped`, `manual` and development `app` | `app` only |

In the first, nobody edited `atlantis.yaml`. The hook generated it, found
the stack, and Atlantis planned it under its derived name:

```text
Ran Plan for project: `acme-development-eu-west-1-new-stack` dir: `stacks/aws/acme-development/eu-west-1/new-stack` workspace: `default`
```

This is the failure from the start of the post, closed: a new stack is
planned because it exists, not because someone remembered to list it.

In the second, both `network` projects are planned, but only the production
plan begins with `Success! The configuration is valid.`, the output of the
`validate` step its workflow adds. The two plans differ because the
directories above them differ, and nothing about either project was written
by hand.

In the third, `skipped` does not appear anywhere, since it is not a
project. `manual` is a project, but it waits until someone comments:

```text
atlantis plan -p acme-production-eu-west-1-manual
```

That comment is also why the naming discussion earlier matters: a project
name is part of how people operate the repository, not only an identifier
in a file.

## Limits of this approach

Generating the list removes one kind of drift, but it does not make Atlantis
understand Terramate, and a few limits are worth stating plainly.

Project names are derived, so they can move. `auto-strip` removes whatever
prefix all stacks share, and adding a stack outside `stacks/aws/` would
change that prefix and rename every project, along with every
`atlantis plan -p` command that names one. Filters never rename projects,
but changes to the tree can. Pinning the prefix with
`--project-name-prefix` (`project_name_prefix` in the config file) avoids
this, and the next post shows it happening.

The default `when_modified` patterns look only at a stack's own directory.
That is fine here, because the demo commits Terramate's generated code, so a
change to a global that the generated code reads also changes the `.tf`
files in every stack below it. Stop committing that code and a change to a
global plans nothing at all, which is the subject of the next post.

Lastly, the two demos in this series have six and five stacks. How
generation time grows with hundreds of stacks is not measured here, and I'd
encourage trying the tool against a copy of a real repository before relying
on it.

## Where to from here?

We've seen how a generated `atlantis.yaml` keeps the project list in step
with the stacks, how project names are derived, how `atlantis_*` globals
carry per-stack settings down the tree, and what changes depending on where
the file is generated. The idea underneath is small: the stacks already
describe what Atlantis needs to know, so Atlantis should learn it from them.

The [next
post](/blog/posts/planning-terramate-stacks-in-atlantis-without-generated-code/)
takes the obvious next step of removing Terramate's generated code from Git
too, and deals with what Atlantis loses when it does. In the meantime, the
demo repository is built to be forked: `make up` starts an Atlantis server
against your copy, and `make e2e` opens the pull requests above and checks
Atlantis' answers.
