Adding a Terraform stack should take one command and no copied files. The layout below makes that true: the directory a stack lives in tells it which account, region and environment it belongs to, and everything a stack would otherwise copy from its neighbour is generated.
The layout problem
The usual layout for multi-account Terraform is a directory per environment
with the same files copied into each: development/, staging/ and
production/, each holding its own backend.tf, providers.tf and main.tf.
It is fine at three environments and one account.
At tens of accounts three things go wrong. Every shared change becomes an N-file edit, so the AWS provider version and the backend settings drift apart silently. The interesting difference between two environments, usually a handful of variables, is buried in a diff of otherwise identical files. And the facts that decide where a stack applies are literal strings in every copy: the account identifier, the AWS profile, the state bucket. Those are exactly the values a copy-paste gets wrong in the direction of production.
A fourth problem is orchestration. Running terraform plan across a few
hundred root modules is not free, so continuous integration needs to know
which ones a pull request can affect. With copied files that signal is
whatever git diff --name-only happens to return, and a change to a shared
input is invisible.
What the tree should do instead is carry the per-stack facts in its own shape, and generate everything that is shared.
What Terramate adds
Terramate is a thin layer over an ordinary Terraform repository. It does not replace Terraform or wrap its state; it adds four ideas, and the whole layout is built from them.
A stack is one root module and its directory, marked by a stack.tm.hcl
file. Terramate lists stacks, runs Terraform in them, and works out which of
them a change affects.
Globals are values declared in a .tm.hcl file for a directory and
everything below it. A value declared at the account level is visible in every
stack of that account; a value declared at the region level in every stack of
that region. A stack inherits its configuration from where it sits.
Code generation turns those values into Terraform. A generate_hcl block
is a template: it renders one .tf file into every stack under it, reading
globals and stack metadata as it goes. The output is committed next to the
hand-written code, so terraform plan sees an ordinary root module.
Imports make the first three reusable. A generator or a shared declaration
is written once, in a file under stacks/imports/, and an import block pulls
it into a directory. That is why the shared things live in one folder: a change
to the backend template or to the AWS provider defaults is one edit in one
file, and every subtree that imports it picks the change up on the next
terramate generate.
The rest of this post is those four ideas placed in a tree.
Three directories that matter
Everything that holds Terraform code goes in one of three places, and the rule for each is short:
| Directory | Holds | Rule |
|---|---|---|
stacks/ | every root module Terramate manages | one stack is one directory containing stack.tm.hcl |
modules/ | reusable modules the stacks call | referenced by relative path from inside the repository |
stacks/imports/ | shared *.tm.hcl fragments | the only place a generate_hcl block lives |
Modules stay in the same repository, and stacks call them by relative path rather than through a registry. Part of the reason is change detection, which the last section comes back to once a stack has been taken apart.
Path as identity
Globals are inherited by everything below the directory that declares them, so the directory a file sits in decides its scope. That turns the path into the declaration:
stacks/├── imports/ shared generators│ ├── 000_terraform.tm.hcl the S3 backend│ └── 001_providers.tm.hcl the providers├── aws/│ ├── aws.tm.hcl imports the generators│ └── acme-development/│ ├── account.tm.hcl account id, profile, environment│ ├── eu-west-1/│ │ ├── region.tm.hcl region name│ │ └── acm/ a stack│ └── us-east-1/│ └── region.tm.hcl└── saas/ stacks without an AWS accountaws.tm.hcl pulls the two generators into the subtree, so every stack below it
gets a backend file and a providers file. The backend generator is shown
below; the providers generator is the next post’s subject.
import { source = "/stacks/imports/000_terraform.tm.hcl"}
import { source = "/stacks/imports/001_providers.tm.hcl"}The two files in between carry everything a stack inherits. An account directory states what is true of every stack in that account:
globals { environment = "development" account_name = "acme-development" account_id = "111111111111" aws_config_profile = "acme-development"}A region directory states the region, and pulls in the AWS provider declaration that depends on it (the next post shows its contents):
globals { region_name = "eu-west-1"}
import { source = "/stacks/imports/aws/provider.tm.hcl"}The generators read those globals. The backend one is the smaller of the two:
the bucket belongs to the account, and the key to the stack’s identifier,
which terramate create assigns:
generate_hcl "000_terramate_generated_terraform.tf" { content { terraform { backend "s3" { bucket = "tf-state-euw1-${global.account_id}" key = "terraform/stacks/by-id/${terramate.stack.id}/terraform.tfstate" region = "eu-west-1" use_lockfile = true } } }}A stack three levels down declares none of this, so relocating one to another
region is a git mv followed by a regeneration:
git mv stacks/aws/acme-development/eu-west-1/acm \ stacks/aws/acme-development/us-east-1/acmterramate generate# [~] 001_terramate_generated_providers.tf
grep region stacks/aws/acme-development/us-east-1/acm/001*.tf# region = "us-east-1"The provider region follows the new path, and nothing in the stack directory was edited. The backend file did not change at all: the bucket is the account’s and the key is the stack’s, and the move touched neither, so no state object moved either.
Moving a stack between accounts is a different case: the bucket changes, so the state object has to be copied to the new bucket under the same key before the first plan. That copy, and the checks around it, are the subject of Migrating Terraform stacks to Terramate with an agent, later in this series.
Stack anatomy
A stack directory holds exactly three kinds of file: the marker, the generated files, and the Terraform someone wrote.
stacks/aws/acme-development/eu-west-1/dns/├── stack.tm.hcl the marker├── 000_terramate_generated_terraform.tf backend, from imports├── 001_terramate_generated_providers.tf providers, from imports└── main.tf module calls, hand writtenThe marker is a stack block carrying a name, a description and an identifier:
stack { name = "dns" description = "Route 53 zone and records for the public API" id = "a55dd02a-628e-4dc6-89a7-2fb83537a9c2"}terramate create writes it, identifier included:
cd stacks/aws/acme-development/eu-west-1terramate create dns# Created stack /stacks/aws/acme-development/eu-west-1/dns
ls -1 dns# 000_terramate_generated_terraform.tf# 001_terramate_generated_providers.tf# stack.tm.hclThe generated files are there because terramate create also runs code
generation, so a new stack arrives with its backend and providers already
written from the imports above it. Nothing in these files is edited by hand.
They are regenerated from the *.tm.hcl sources and committed, which keeps
terraform plan working from a plain checkout, and continuous integration runs
terramate generate --detailed-exit-code to fail a branch whose committed
output is stale.
The only file a person writes is main.tf: plain Terraform, calling modules by
relative path. For the acm stack that is one module, five levels up to the
repository root and back down into modules/:
module "certificate" { source = "../../../../../modules/acm-certificate" domain_name = "api.example.com"}In the production repository this layout was first set up and tested in, about fifteen generator files, some two thousand lines in all, produce close to two thousand Terraform files and tens of thousands of lines across roughly five hundred stacks. None of it is reviewed line by line, because none of it was written by anyone.
Change detection follows module calls
That relative path is also what makes change detection transitive. Terramate
compares the branch with the default branch, parses the module blocks in each
stack’s *.tf files and follows local sources, so a commit that touches
nothing but a module marks the stacks that call it as changed:
git switch -c feat/change-validation-method# edit modules/acm-certificate/main.tf, nothing else
terramate list --changed# stacks/aws/acme-development/eu-west-1/acmA module reference that resolves outside the project root is skipped with a warning, and a module pulled from a registry cannot be diffed at all. Both mean the same thing: code that stacks depend on belongs inside the tree, or continuous integration plans the wrong set of stacks.
Limitations
The nesting that makes the path meaningful also makes the tree hard to read. A
stack lives five or six levels down, and the only practical way to see what
exists is terramate list or a fuzzy finder over its output.
Change detection is based on what is inside a stack directory, so a change to a
shared import marks a stack as changed only when the generated output actually
changes. Editing a comment in an import and regenerating leaves
terramate list --changed empty. That keeps the pipeline quiet, and it also
means the import edit itself is not the trigger, the regenerated file is.
The layout assumes a stack belongs to one account and one region. A stack that spans two accounts has to pick a home directory, which also picks its state bucket, and that choice is a convention rather than something the tree enforces.
Finally, this is a target, not a migration path. An existing flat repository converts one stack at a time, and the legacy tree coexists with the new one for as long as that takes.
What’s next?
The generators are where the layout pays off. Generating Terraform providers with Terramate takes apart the one that renders every stack’s provider blocks; the credentials those blocks reference come from One Terraform module for provider credentials, a note that stands on its own and needs no Terramate to be useful.
Comments