From 2–3 days to 30 minutes: an internal IaC platform with Terragrunt stacks

A new team needed an AWS environment: an account baseline, a VPC, DNS, an EKS cluster, GitOps, monitoring, security settings. A DevOps engineer spent two to three days on it: copying folders from another team's live repository, renaming things, hunting for the one value that was different last time.

Today the same team writes about fifteen lines, opens a pull request and has a working environment about thirty minutes after the merge. Most of that time is EKS creating its control plane.

Here is how the platform is built, what I learned while building it, and a clean-room reference implementation you can read and run: github.com/devopsmher/terragrunt-platform.

Where we started

The company had more than seventy DevOps engineers spread across domain teams. Every team had its own Terraform, and most of it had been copied from another team at some point.

That worked until it didn't:

  • Copies drifted. Two environments built from "the same" code had different subnet layouts, different tags, different IAM.
  • Modules floated. ref=main was common, so a change in a shared module reached production on the next plan, unreviewed.
  • Bootstrap was manual. State buckets, CI credentials, DNS delegation: a checklist in someone's head.
  • Reviews were useless. When every environment is a few thousand lines of copied code, nobody reads the diff.

What I wanted instead

  • Self-service. A team describes what it needs; it does not write Terraform.
  • The same result every time. One definition of "a team environment", not twenty variations.
  • Versioned and reviewable. Every change to an environment is a small diff with a plan attached.
  • Secure by default. No long-lived keys in CI, no "admin because it was easier".

Four layers

live repository → stack → units → modules → AWS values only composition contracts Terraform 

Modules are plain Terraform. Mostly thin wrappers over well-known modules from terraform-aws-modules, pinned to exact versions, with our defaults on top: tags, subnet layout, access rules.

Units wrap one module each and add a contract: which values the unit needs, which outputs it gives, which other units it depends on.

Stacks compose units into a product. In the reference implementation that product is team-environment: account baseline, network, DNS and EKS with Karpenter.

Live repositories belong to domain teams. They hold values and a pinned release, nothing else.

This is a whole environment from the team's side:

stack "platform" { source = "${local.catalog}//stacks/team-environment?ref=${local.version}" path = "platform" values = { catalog = local.catalog version = local.version team = "payments" environment = "dev" cidr = "10.20.0.0/16" zone_name = "payments-dev.example.com" kubernetes_version = "1.35" github_repository = "example-org/payments-live" state_bucket = "tfstate-${local.env.locals.account_id}-${local.env.locals.region}" } } 

Terragrunt stacks made this possible without a custom generator: terragrunt stack generate expands the stack into units, and terragrunt stack run plan runs them in dependency order.

Contracts between layers

The most useful decision was to treat every boundary as a contract and write it down where people look first: at the top of the unit.

# Unit: eks — cluster, system node group and Karpenter with interruption handling. # # Contract # values in : catalog, version, name, kubernetes_version, network_path, account_baseline_path, tags # needs : network.vpc_id, network.private_subnet_ids, network.intra_subnet_ids, # account_baseline.apply_role_arn # outputs : cluster_name, cluster_endpoint, oidc_provider_arn, karpenter_queue_name, ... dependency "network" { config_path = values.network_path mock_outputs = { vpc_id = "vpc-00000000000000000" private_subnet_ids = ["subnet-00000000000000001", "subnet-00000000000000002", "subnet-00000000000000003"] intra_subnet_ids = ["subnet-00000000000000004", "subnet-00000000000000005", "subnet-00000000000000006"] } mock_outputs_allowed_terraform_commands = ["init", "validate", "plan"] } 

Three things make the contract real rather than decorative:

  1. Missing values fail early. A stack that forgets a value fails at generation time, not twenty minutes into an apply.
  2. Mock outputs only for planning. A brand-new environment can be planned before its VPC exists, but mocks are never allowed for apply.
  3. Strict input validation in CI. terragrunt hcl validate --inputs --strict fails if a unit passes an input its module does not declare, or misses one it needs.

Renaming an output is a breaking change, and the version number says so.

Versions and release discipline

The catalog (modules, units and stacks) is released together under one semver tag:

  • MAJOR: a breaking contract change or a resource replacement.
  • MINOR: a new optional value or a new unit.
  • PATCH: a fix that changes no contract.

Live repositories pin an exact tag. An upgrade is a one-line pull request: dev first, read the plan, then prod. A small script in CI rejects anything that floats: branches, missing refs, version ranges for registry modules.

./live/payments/dev/terragrunt.stack.hcl:6: version is not a release tag (vX.Y.Z) ./units/dns/terragrunt.hcl:12: source is not pinned with ?ref=${values.version} ./modules/network/main.tf:21: module version is not exact (X.Y.Z) pinning check failed: see the lines above 

It sounds strict. In practice it removed a whole class of "nobody changed anything, but the plan is different" incidents.

Plan in the pull request, apply after merge, no keys

CI has no AWS keys. Every account gets two roles from the account-baseline unit, and GitHub Actions exchanges its OIDC token for short-lived credentials:

  • Plan role: read-only, trusted for pull requests of the team's live repository. With S3 native locking it can still write the .tflock file it needs.
  • Apply role: trusted only for jobs that run in the protected GitHub environment of that account, for example payments-dev, which requires an approval.

Trusting an environment instead of a branch matters: a workflow edited inside a pull request still cannot assume the apply role, because its job never passes the environment's protection rules.

A pull request that touches live/payments/dev plans only that environment. A change to the shared root.hcl plans every environment. Each environment gets its own job, its own role and its own concurrency group.

A quick start that a team can actually follow

The platform is only self-service if a team can use it without asking the platform team. The documentation was part of the product from day one, written in two languages, with a quick start of four steps:

  1. Copy the example environment directory and change the values.
  2. Bootstrap once: an admin applies the account baseline, which creates the state bucket and the CI roles.
  3. Open a pull request: CI plans; merge and approve: CI applies.
  4. Delegate the DNS zone from the parent domain.

The one manual step, bootstrap, is written down explicitly instead of hidden. Pretending there is no chicken-and-egg problem does not make it go away.

Results

  • A new team environment: from 2–3 days to about 30 minutes, most of it EKS.
  • Every environment comes from the same versioned definition; differences are visible as values, not as code.
  • Upgrades are one-line pull requests with a plan.
  • I designed and built the platform on my own in under four months: modules, stacks, contracts, CI and documentation.

What I learned

  • Start from contracts, not modules. Deciding what a unit takes and gives was harder and more important than any Terraform.
  • Keep stacks thin. Composition belongs in stacks; logic belongs in modules, where it can be tested.
  • Pin everything, including your own catalog. If your own code floats, nobody will take pinning seriously.
  • Mocks are for planning only. Allowing them for apply turns a missing dependency into a broken environment.
  • Documentation is part of the platform. The quick start removed more support requests than any feature.

Working with an AI coding agent, with guardrails

I used Claude Code as a pair engineer through the whole project: module and stack code, CI scripts, merge request reviews, documentation and its translation. It also had read access to Jira, Confluence and Kubernetes through MCP.

The rules were simple and non-negotiable: the agent had read-only access to clouds and clusters, it never ran apply, and every change was reviewed by me before commit. With those rules, an agent made me faster without taking any decision about production away from a human.

Try it

The reference implementation is on GitHub: devopsmher/terragrunt-platform. It is a clean-room rewrite of the pattern and contains no code from the original project. Every module passes terraform validate, the example stack generates and passes strict input validation, and the CI workflows are ready for a real account.

Next in the platform: handing every new cluster over to GitOps right after creation.

Mher Poghosyan is a Senior DevOps & Platform Engineer in Yerevan, Armenia. He builds Kubernetes platforms on AWS and GCP. LinkedIn · GitHub