Keeping DRY Across Many Terraform Root Modules

Everyone who has run a big Terraform state eventually splits it up. You get your fast plans back, a smaller blast radius, and teams that aren't queueing behind each other all day. It's the right call.

Now you write everything thirty times instead of once.

Every root needs a backend block. Every root needs a providers block. Every root needs the region, the tags, the subscription id, the common labels. Add root module thirty-one and you copy the lot again.

That used to be a good argument against splitting things up. These days Claude Code will copy thirty files for you in about ten seconds and get the tags right, which is more than I'd manage at five in the afternoon. So if typing were the only problem here, it's solved.

It isn't. Thirty copies of a value is thirty places that can disagree, with nothing to tell you when they do. Nobody sets the wrong region deliberately. You update twenty-nine roots, miss one, and find out about it months later when something turns up somewhere it shouldn't be. By then the copies have drifted apart and the only record of what the value was meant to be is spread across thirty files.

An agent doesn't help with that part. It writes the thirty backend blocks once, from what it knew that day, and then it stops. Change the bucket next March and somebody has to go round all thirty again. The agent isn't sitting there watching your infrastructure.

Plenty of teams get halfway and stop here. The monolith was awful, but at least it was one file.

Below: where the copying comes from, what the other tools do about it, and what Snap CD does about it.

Why Terraform forces the duplication

Some of this you can't fix by being more disciplined. Terraform simply requires it.

The backend block. Terraform wants backend written out inside the terraform block, in the root, literally. You can't put it in a variable, you can't inherit it from a module, and you can't build it at runtime:

terraform {
  backend "s3" {
    bucket = "my-state"          # no variables allowed here
    key    = "networking/vpc"    # and this differs per root
  }
}

Partial configuration takes the sting out, since you can leave the block empty and pass -backend-config at init. But the empty block still has to be sitting there in all thirty roots. There's no way to write it once.

Provider blocks, nearly as bad. These can at least take variables. But a root module has to have one, and a child module you want to reuse mustn't declare one if anyone's going to call it with for_each or count. So provider config belongs in the root, and you have thirty roots.

Then the shared values. Thirty roots all deploying into westeurope, and every one of them needs telling. Terraform has no idea that root modules might be related, so it has no way to give them something in common.

All three come back to the same thing. Child modules let you share code. A root module is what Terraform runs. How two root modules relate to each other is a question Terraform never answers, and every tool below is somebody's attempt at answering it.

What other tools do about it

Terragrunt

Terragrunt has been the usual answer since 2016. You keep a second tree of terragrunt.hcl files next to your Terraform, sharing settings with include and writing files with generate:

# terragrunt.hcl at the root of the tree
remote_state {
  backend = "s3"
  config = {
    bucket = "my-state"
    key    = "${path_relative_to_include()}/terraform.tfstate"
    region = "eu-west-1"
  }
}

generate "provider" {
  path      = "provider.tf"
  if_exists = "overwrite"
  contents  = <<EOF
provider "aws" {
  region = "eu-west-1"
}
EOF
}

Children pick that up through include, and generate drops the provider file into the working directory just before Terraform runs. The backend block still ends up in every root, but you only typed it once.

It works. You pay for it with another language to learn, a second file tree that has to stay in step with the first, and a wrapper command in front of everything. If the copying is what's bothering you, this is still the quickest way out.

Terramate and Atmos

Terramate and Atmos go further and write the Terraform files for you, backend block and all, from a shorter description. Atmos in particular is built around taking one component and deploying it over and over with different values, which is more or less the problem described above.

Both have firmer opinions than Terragrunt about how your repo should look. Easy enough on a new project, painful on an existing one.

Workspaces, and why they are not this

Workspaces let one configuration have several states, which sounds close. But every workspace runs the same code against the same backend bucket, differing only in a prefix on the state key. That covers dev, staging and prod of a single component. Thirty different components need thirty different configurations, and workspaces don't give you that.

Doing nothing

More common than any of the above: copy the files, let them drift apart, deal with it when something breaks. Nothing to install, nothing to learn, and at five roots it works fine. Somewhere between ten and twenty it stops working, usually without anyone noticing.

How Snap CD approaches it

Snap CD arranges things as Stacks, Namespaces and Modules. A Module is one root module with its own source and its own state file. A Namespace is where you group the Modules that belong together, and it's also where anything they share gets declared.

Set it on the Namespace, override it on the Module. That's the whole pattern, and everything below is just that applied to a different kind of thing. The Namespace is as high as it goes, so twelve Namespaces that want the same value still get told twelve times.

Extra Files: the files Terraform makes you repeat

A Namespace Extra File gets written into every Module in the Namespace once the code is checked out and before anything runs. Terraform wants the backend block in the root, so this is how it gets there:

resource "snapcd_namespace_extra_file" "backend" {
  namespace_id = snapcd_namespace.networking.id
  file_name    = "extra_root.tf"
  overwrite    = false
  contents     = <<-EOT
    terraform {
      backend "http" {}
    }
  EOT
}

Same for a shared providers.tf, a .tflint.hcl, or anything else every root needs and none of them ought to own. A Module that genuinely needs something different sets ignore_namespace_extra_files and goes its own way.

Your Terraform doesn't get any shorter. Each root still declares the variables it takes, as it should, because that's how anyone knows what to pass it. You only lose the files you were copying because Terraform insists on finding them in the root.

Namespace Inputs: values declared once

A Namespace Input is a parameter or an environment variable that you set on the Namespace and every Module underneath inherits:

resource "snapcd_namespace_input_from_literal" "region" {
  namespace_id  = snapcd_namespace.networking.id
  name          = "region"
  input_kind    = "Param"
  usage_mode    = "UseByDefault"
  literal_value = "westeurope"
}

UseByDefault hands the value to every Module. UseIfSelected makes it available, but each Module has to ask for it by name, and can rename it while it's at it. You'll want both: a region is a default, a feature flag is something you opt into.

Parameters go into a .tfvars file, which means a Module that never declares the variable simply ignores it. Environment variables turn up on the Runner with a SNAPCD_ENV_ prefix.

Inputs From Definition: values Snap CD already knows

Some values aren't really settings at all. They're facts about the Module, and Snap CD already knows them. An Input From Definition just takes the value from the Module itself: ModuleName, NamespaceName, StackName, SourceUrl, SourceRevision, SourceSubdirectory, and a handful of others.

resource "snapcd_namespace_input_from_definition" "keys" {
  for_each = {
    SNAPCD_NAMESPACE_NAME = "NamespaceName"
    SNAPCD_MODULE_NAME    = "ModuleName"
  }

  namespace_id    = snapcd_namespace.networking.id
  name            = each.key
  definition_name = each.value
  input_kind      = "EnvVar"
  usage_mode      = "UseByDefault"
}

Build your naming rules and tags out of these and there's nothing left to keep up to date.

The two together

This part is worth copying even if you never touch Snap CD. Give each Module its own name as an environment variable, then write the backend config once and build the state key out of that:

locals {
  key = "$${SNAPCD_NAMESPACE_NAME}--$${SNAPCD_MODULE_NAME}"
}

resource "snapcd_namespace_terraform_array_flag" "backend" {
  for_each = {
    address      = "${local.api}/${local.key}"
    lock_address = "${local.api}/${local.key}/lock"
    username     = "$${SNAPCD_CLIENT_ID}"
    password     = "$${SNAPCD_CLIENT_SECRET}"
  }

  namespace_id = snapcd_namespace.networking.id
  task         = "Init"
  flag         = "BackendConfig"
  value        = "${each.key}=${each.value}"
}

Now every Module works out its own state key from its own name. Root module thirty-one needs no backend config whatsoever and still gets the right one, because nobody typed it.

One backend address written on the networking Namespace, with the namespace and module names interpolated into it. Three Modules underneath - vpc, database and cluster - each resolving it to a different state key: networking--vpc, networking--database, networking--cluster.

The state key was never worth writing down. It was always just the Module's name.

Four places a Module's values come from, and only the first is something a person had to decide on:

Four sources for a Module's inputs. From a literal, which you typed. From the Module itself - ModuleName, StackName, SourceRevision - which Snap CD knows. From another Module's output, which the apply produced. From a Secret, which the Secret Store holds.

Flags, hooks and policies work the same way

Flags set command-line arguments per task for everything in the Namespace. Hooks run scripts before or after a task, with the closest one winning. Policies check the actual plan against OPA rules before it's applied.

Policies break the override rule on purpose. They stack up, and no Module can switch one off. A rule you can switch off isn't a rule.

You set all of this up with Snap CD's own Terraform provider, so it's Terraform, you review it like Terraform, and for_each does what you'd expect.

Wiring values between roots

Everything so far has been values you know in advance. Plenty of them you don't: a subnet id, a cluster endpoint, a generated password. Those only exist once something has been applied.

Terragrunt handles these with dependency blocks that read the other module's state file. That gets the value across, but whatever runs Terragrunt now needs read access to every state backend involved. Twenty modules across three clouds is a lot of credentials to have sitting on one machine, and reading a single output means opening the whole state file it sits in.

Snap CD keeps the outputs itself. Each time a job's outputs come back different it writes a new Output Set, and the sensitive ones go off to a Secret Store with just a reference kept behind. A Module asking for a value gets that value, and never goes near the other one's state:

resource "snapcd_module_input_from_output" "subnet" {
  input_kind       = "Param"
  module_id        = snapcd_module.cluster.id
  name             = "deploy_to_subnet_id"
  output_module_id = snapcd_module.vpc.id
  output_name      = "private_subnet_id"
}

If you want all of them, snapcd_module_input_from_output_set pulls the lot in one resource, each output becoming a variable of the same name. Ones you never declared are ignored, since parameters go into a .tfvars file and Terraform skips what it doesn't recognise. There's also snapcd_depends_on_module for ordering on its own, when B has to run after A but needs nothing from it.

Because the outputs sit on the server, wiring crosses Namespaces freely. The networking team's vpc can feed the platform team's cluster without either side knowing where the other keeps its state. The Stack is the wall; inside one, anything can reference anything.

The server also sees the Output Set change, so it can act on it. Set trigger_on_upstream_output_changed and everything downstream re-plans on its own, with nobody running a command. Terragrunt wires values up at the moment you invoke it, and Snap CD knows the graph whether you're there or not.

Deploying the deployer

A Module runs Terraform. The Snap CD provider is a provider like any other. So there's nothing to stop a Module's own code creating Namespaces and Modules, which means you can hand Snap CD a repo describing a deployment and have it deploy that:

resource "snapcd_namespace" "bootstrap" {
  name           = "bootstrap"
  stack_id       = data.snapcd_stack.default.id
  default_engine = "OpenTofu"
}

resource "snapcd_module" "sample_deployment" {
  name            = "sample-deployment"
  namespace_id    = snapcd_namespace.bootstrap.id
  source_revision = "main"
  source_url      = "https://github.com/snapcd-samples/sample-deployment.git"
  runner_id       = data.snapcd_runner.default.id
}

Apply that once from your laptop and you get a Namespace called bootstrap with one Module in it. When that Module deploys, the Runner clones the repo and runs tofu apply, which creates the inner Namespace and its vpc, database, cluster and app Modules, already wired to each other. Push to the repo after that and Snap CD picks it up:

bootstrap
└── sample-deployment
    └── my-sample-namespace
        ├── vpc ──┬──> cluster ──┬──> app
        │         └──> database ─┘

The project calls this module-in-module, and there's a runnable sample plus a short video walking through it.

Something always has to create the first thing. Here you apply one root by hand, once, and it takes over whatever you'd been applying manually. Point the inner project's backend at the state file you were already using, pass MigrateState on init, and the first plan comes up empty because the state is already sitting there. After that you stop applying locally.

Terragrunt can't do this, though not through any fault of its own. It's a CLI. Your tree describes deployments and you run it, so there's nothing sitting there that could itself be deployed. A tree that generates a tree just gets you more files.

The same shape turns up on the way out of a monolith. demonolith split refactor writes a bootstrap module at roots/snapcd/ from the split map: a Namespace, one Module per new directory, every cross-module value as a snapcd_module_input_from_output, every ordering edge as a snapcd_depends_on_module. Apply it and Snap CD starts doing what the monolith had been doing quietly all along. The whole thing is read off the code rather than written by hand, which only works because the wiring is Terraform too.

Choosing between them

These aren't quite alternatives to each other. They overlap, but they're aimed at different things.

Terragrunt gets rid of the copying at the moment you run a command, by sharing settings and generating files from a second tree. If that's all you're after, it's the quickest route and a well-worn one.

Terramate and Atmos write the Terraform for you from something shorter. Worth it if you've got a lot of near-identical stacks and you don't mind arranging your repo the way they'd like.

Snap CD is a server that runs your deployments. It can share settings because it already knows which Modules belong together, so the sharing falls out of the grouping rather than being the reason for it. If all you want is less copying, this is far more than you need. It earns its place when that same grouping starts carrying your dependencies, approvals, policies and permissions, and when you'd rather the deployments ran themselves than waited for someone to type a command.

Which one removes the most copying matters less than whether you want a tool you run or something that's already running. The longer Terragrunt comparison covers the rest.

The point

DRY was never really about typing less. It was about having one place to go when something changes.

Splitting the monolith gets you lots of small, safe deployments, which is what you wanted. Left alone it also gets you lots of small copies of the same value, which isn't. Whatever you end up using, the test is the same: when the region changes, how many files do you open?

Snap CD

Intelligent GitOps for Infrastructure as Code. Automate, orchestrate, and scale your infrastructure deployments with confidence.


© 2026 Snap CD. All rights reserved.

An unhandled error has occurred. Reload 🗙