Migrating from Terragrunt
This reference covers both Terragrunt shapes: the classic pattern (terragrunt.hcl,
include, find_in_parent_folders()) and the newer Stacks pattern
(terragrunt.stack.hcl, unit blocks). For the full user-facing prose tutorial with
complete concept and function mapping tables, see
atmos.tools/migration/terragrunt. This
reference distills that tutorial into agent-actionable recipes and adds material the
tutorial does not cover: how to translate Terragrunt Stacks.
Identifying the User's Shape
Check the repository for these signals before proposing anything:
| Signal | Shape | Recipe |
|---|---|---|
terragrunt.hcl files, no terragrunt.stack.hcl | Classic | This reference, sections below |
terragrunt.stack.hcl present | Stacks | This reference, plus Terragrunt Stacks below |
| A Gruntwork "Runbooks" scaffold on top of Stacks | Stacks + scaffolding | Same as Stacks — the scaffolding layer does not change the mapping |
Mixed repositories exist. Treat each terragrunt.hcl or terragrunt.stack.hcl unit
independently, the same way multiple root modules get treated independently in a
native-Terraform migration.
Core Concept Mapping
Each pair below shows the Terragrunt construct and its direct Atmos equivalent. Full function-by-function tables live in the canonical tutorial; this section covers only the constructs an agent needs to translate a real project.
include and find_in_parent_folders() → stack imports
Terragrunt's DRY mechanism climbs the directory tree at run time. Atmos resolves
inheritance declaratively through import: and deep-merge, with no directory-walk
step:
# terragrunt.hclinclude "root" {path = find_in_parent_folders("root.hcl")}
# stacks/orgs/acme/prod/us-east-1.yamlimport:- orgs/acme/_defaults- mixins/region/us-east-1
Atmos's import system supports deeper inheritance chains (org → tenant → stage → region) than Terragrunt's parent-folder walk, and it works the same way whether the imported file lives next to the stack or in a separate repository. Route deeper organization questions to the atmos-design-patterns skill.
generate "backend" / generate "provider" → automatic backend and provider generation
Terragrunt's root.hcl typically has a generate "provider" block and a
remote_state { generate = {...} } block that each write one file. Atmos generates
both automatically from stack configuration — no generate block needed for either:
generate "provider" {path = "provider.tf"if_exists = "overwrite_terragrunt"contents = <<EOFprovider "aws" {region = "${local.aws_region}"}EOF}remote_state {backend = "s3"config = { bucket = "...", key = "...", region = "..." }generate = { path = "backend.tf", if_exists = "overwrite_terragrunt" }}
terraform:backend_type: s3backend:s3:bucket: acme-prod-tfstatekey: terraform.tfstateregion: us-east-1
Atmos writes backend.tf.json from this section automatically at plan/apply time. No
provider-file generation is needed either — set provider region and account
restrictions through stack vars: that the component's own providers.tf reads, or
through the providers: stack section (website/docs/stacks/providers.mdx, e.g.
terraform.providers: at the component-type level or providers: on the component)
when the provider block itself needs per-stack values Atmos does not already inject
through an identity.
If a Terragrunt unit's generate block writes something other than a backend or
provider file, Atmos has a direct, more general equivalent: the declarative generate:
stack section (website/docs/stacks/generate.mdx), which writes arbitrary files from
stack configuration with full templating and a 5-level merge (global, component-type,
base component, component, and override). This covers the general case Terragrunt's
generate block handles; backend and provider files are simply the two cases Atmos
automates without any generate: configuration at all.
dependency blocks → dependencies.components and !terraform.state
Terragrunt's dependency block does two things at once: it orders execution and it
reads another unit's outputs. Atmos splits these into two primitives that compose:
dependency "vpc" {config_path = "../vpc"}inputs = {vpc_id = dependency.vpc.outputs.vpc_id}
components:terraform:eks-cluster:dependencies:components:- name: vpcvars:vpc_id: !terraform.state vpc vpc_id
dependencies.components orders execution across atmos terraform apply --all and
--affected, the same job dependency does in Terragrunt. !terraform.state reads
the real value once the dependency is deployed.
mock_outputs → the mocks component field and --use-mocks
Atmos has a first-class mock mode, but its scope differs from Terragrunt's
per-dependency mock_outputs. Terragrunt lets each dependency block set its own
mock values for the same target module; Atmos stores one mocks map on the producer
component (in a given stack), shared by every consumer that reads it. Use this
mapping directly only when every consumer of a producer is fine with the same mock
values. If consumers genuinely need different values for the same output, give
each consumer its own producer component instance (a second logical component
name pointing at the same Terraform root module) with its own mocks block,
rather than expecting one shared map to vary per caller.
dependency "vpc" {config_path = "../vpc"mock_outputs = {vpc_id = "vpc-mock1234"private_subnet_ids = ["subnet-a", "subnet-b"]}mock_outputs_allowed_terraform_commands = ["validate", "plan"]}
components:terraform:vpc:mocks:vpc_id: vpc-mock1234private_subnet_ids: [subnet-a, subnet-b]
atmos terraform plan eks-cluster -s dev --use-mocksatmos describe component eks-cluster -s dev --use-mocks
With --use-mocks, !terraform.state vpc vpc_id and !terraform.output vpc vpc_id
resolve from vpc's mocks map instead of real state, with no Terraform init,
authentication, or backend read at all. Without --use-mocks, the same expression
resolves the real value as usual. This matches Terragrunt's
mock_outputs_allowed_terraform_commands scoping more closely than a default-value
expression does: --use-mocks is rejected outright on apply, deploy, and
destroy, so a mock value can never reach a mutating Terraform operation, and an
undeclared mocks entry is a hard error rather than a silent fallback. A working,
provider-free reference lives at examples/terraform-component-mocks in the Atmos
repository.
Reserve the YQ-default pattern (!terraform.state vpc '.vpc_id // "vpc-mock1234"')
for the narrower case of a placeholder that should also apply during a normal,
non-mock run against a dependency that genuinely has not deployed yet — for example,
while bringing up a dependency graph for the first time. mocks and --use-mocks are
the right default for anything resembling Terragrunt's mock_outputs.
Terraform source pinning → vendoring
terraform {source = "git::git@github.com:acme/modules.git//vpc?ref=v1.2.3"}
# vendor.yamlspec:sources:- component: vpcsource: github.com/acme/modules.git//vpc?ref={{.Version}}version: v1.2.3targets:- components/terraform/vpc
atmos vendor pull pulls the pinned module into components/terraform/vpc as a
one-time step, rather than Terragrunt's implicit download on every run. This is a real
workflow difference worth naming to the user directly: vendoring is explicit
(atmos vendor pull, typically a CI or setup step), not automatic on every
atmos terraform plan. Route deeper vendoring questions (include/exclude globs,
atmos vendor diff/update) to the atmos-vendoring
skill.
before_hook / after_hook → Atmos hooks
terraform {before_hook "package" {commands = ["apply", "plan", "destroy"]execute = ["./scripts/package.sh", "./src", "./handler.zip"]}}
components:terraform:lambda-service:hooks:package:events:- before.terraform.plan- before.terraform.apply- before.terraform.destroykind: steptype: archivewith:source: src/destination: handler.zip
For the common case of packaging a directory into a zip or tar before Terraform runs
(a Lambda deployment artifact is the most frequent example), use the native
type: archive step through the kind: step hook bridge shown above, not a
kind: command hook shelling out to zip/tar. The archive step runs on the Go
standard library alone, so it behaves identically on Windows, whereas a shell hook
depends on zip/tar being installed and behaving the same way across platforms.
For any other packaging or validation logic a before_hook/after_hook runs, use the
generic kind: command hook, or one of the named scanner kinds (checkov, trivy,
kics, infracost) if the hook wraps a security or cost tool Atmos already has a
built-in kind for. See the atmos-hooks skill for the
full kind list and event lifecycle.
Known rough edge: if atmos terraform plan reports the archive source does not
exist even though the path shown above looks correct, use an absolute path for
source/destination instead of a relative one. Some Atmos versions do not resolve
a relative archive-step path against the component directory when the step runs as a
hook.
Terragrunt Stacks Map Directly to Atmos Stacks
A common misreading is that terragrunt.stack.hcl needs a special Atmos feature to
translate — it does not. Terragrunt Stacks retrofits "declare a reusable, parameterized
group of units" onto Terragrunt's original one-terragrunt.hcl-per-directory
architecture. Atmos never had that constraint: an Atmos stack already is a named,
declarative, parameterized collection of components, resolved without any
materialization step. The mapping is direct, not a workaround:
# terragrunt.stack.hclunit "lambda_service" {source = "github.com/acme/catalog//units/lambda-service"path = "service"values = { name = "my-service", runtime = "nodejs22.x" }autoinclude {dependency "role" { config_path = unit.role.path }}}unit "role" {source = "github.com/acme/catalog//units/iam-role"path = "role"}
# One Atmos stack manifest — no materialization, no per-unit directorycomponents:terraform:lambda-service:dependencies:components:- name: iam-rolevars:name: my-serviceruntime: nodejs22.xiam_role_arn: !terraform.state iam-role '.arn // "pending"'iam-role:vars:name: my-service-role
Run this with atmos terraform apply --all -s <stack> (or --affected for a subset).
Do not reach for compositions here — that primitive groups components for local
multi-kind operation (container, compose, terraform together) and explicitly does not
define execution order. Ordering and value-passing across units is
dependencies.components and !terraform.state, exactly as shown above, whether the
source project uses classic Terragrunt or Terragrunt Stacks.
One difference worth naming to the user: Terragrunt Stacks pins each unit's catalog
source and version inline in the unit block. Atmos separates that concern into
vendor.yaml (or direct component authoring), so a stack manifest's components:
section only carries values, not source references. This is a cleaner separation of
concerns, not a missing capability — see
from-terragrunt.md above for the vendoring
side.
Migration Workflow
- Inventory the source repository. Find every
terragrunt.hclandterragrunt.stack.hcl, and everyinclude/dependencyreference between them, to build a migration order — the same reconnaissance step a native-Terraform migration starts with. - Stand up
atmos.yamland one stack file for a single unit, convertingincludeandinputsper the concept mapping above. - Wire
dependencyblocks todependencies.componentsand!terraform.state, mappingmock_outputsto the producer component'smocks:field and using--use-mocksfor read-only planning/description (reserve the YQ// "default"pattern for real dependencies that simply have not deployed yet). - Convert
generateblocks. Backend and provider generation need no configuration at all; anything else becomes agenerate:stack section. - Validate with
atmos validate stacks, then compareatmos list affected(human-readable table; commit your change first — it diffs committed trees, not the working tree) against the equivalent Terragrunt change-detection output. Useatmos describe affectedinstead when scripting/CI needs the JSON/YAML form. - Make the result testable. Wire the component's stack manifest to an
aws/emulator(or the matching cloud target) identity soatmos terraform plan/applyruns with zero real cloud credentials before the user connects a real account. See the atmos-emulator skill. - Repeat per unit, using remote-state-bridge.md to keep
not-yet-migrated units reachable via
!terraform.stateduring the transition.
When to Escalate to Other Skills
- Stack organization (orgs, tenants, accounts, regions) → atmos-design-patterns
- Vendoring converted module sources → atmos-vendoring
- Abstract components, inheritance, catalog patterns → atmos-components
- Deep merging, imports, overrides → atmos-stacks
- Provider credentials, identity chaining → atmos-auth
- Local emulator setup for testing the migrated stack → atmos-emulator
- YAML function selection (
!terraform.output,!terraform.state,!store) → atmos-yaml-functions - Hooks beyond packaging (scanners, cost estimation, custom commands) → atmos-hooks
- Progressive, component-by-component migration → remote-state-bridge.md
Anti-Patterns
- "Terragrunt Stacks needs the
compositionsfeature." No — an ordinary stack manifest withdependencies.componentsalready covers it.compositionssolves a different problem (grouping components of different kinds for local operation). - "Atmos cannot run components in parallel like
run-all --parallelism." No —atmos terraform apply --all --max-concurrency Nruns a dependency-ordered concurrent scheduler. Confirm the installed Atmos version if--max-concurrencydoes not appear in--help; it shipped after a long sequential-only period. - "Port every
generateblock one-to-one with akind: commandhook." No — check whether the block only writes a backend or provider file first (automatic, no configuration needed) before reaching for a hook. - "There is a
terragrunt://import scheme that convertsterragrunt.hclautomatically." No — this is a documented future proposal, not a shipped feature. Translation is manual or agent-assisted.
Additional Resources
- remote-state-bridge.md — progressive migration technique, identical to the native-Terraform case
- atmos.tools/migration/terragrunt — the full prose tutorial, including complete concept and function mapping tables