Automating a Terraform Monolith Split with Demonolith
Splitting a Terraform monolith by hand takes ten mechanical steps. First you pin the session, then you split the code (find the seams, refactor the code, copy the backend over, review the result), then you migrate the state (carve the state, copy the credentials and inputs, prove it offline, push, adopt). Splitting a Terraform Monolith walks through all of them. There's a lot of room to slip up, and the state half runs against real infrastructure. That's usually why teams keep putting the split off.
Demonolith is a Go CLI that runs the whole procedure for you and checks its own work along the way. This guide shows you how to use it. It assumes you've read the manual walkthrough, since Demonolith's commands follow those ten steps closely and that guide explains what each one is doing under the hood.
If you'd rather just watch it run, sample-deployment-demonolith is a complete, runnable example: a deliberately messy monolith with remote state that gets split, proven inert, and migrated by a handful of scripts, no cloud account needed. The output shown throughout this guide comes from that repo.
Annotate the code, and let the tool plan
You don't write a migration script. You annotate the monolith: a # @demono:move <module> comment above each resource or module block says which root it should end up in. Demonolith reads those annotations, works out the boundaries, and writes a map of the full plan before it touches anything.
# @demono:move networking
resource "aws_vpc" "main" {
cidr_block = "10.0.0.0/16"
}
# @demono:move networking
resource "aws_subnet" "private" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
}
# @demono:move compute
resource "aws_eks_cluster" "main" {
vpc_config {
subnet_ids = [aws_subnet.private.id]
}
}
A few rules apply to the annotations:
- A
resourceormoduleblock takes exactly one target. It holds state, so it lives in one place. - A block with no annotation goes to a catchall remainder module (
--remainder-module, defaultlegacy). This is the same "whatever's left" module you'd end up with when splitting the state by hand. - Don't annotate
datablocks. A data source follows its consumers, like locals and variables do: it gets copied into every root that references it and re-read there. If you annotate one, the run errors out. - If something looks like an annotation but doesn't parse, that's also an error. A typo stops the run rather than quietly putting a resource in the wrong place.
If you'd rather not annotate the whole tree by hand, refactor map --interactive asks for the run's parameters and walks you through the un-annotated blocks one at a time, writing your answers back into the source as annotations. That way the result is still in git, and you can re-run it later without the prompts.
Two command families, split at the code/state line
The tool splits along the same line the manual procedure does. Refactoring the code is offline work you can undo with git. Migrating the state touches real backends and you can't. Demonolith keeps these in two separate command families so you don't cross that line by accident.
demonolith refactor # map → run → validate → diff (the code split) refactor map # analyze → write the map (the review artifact) refactor run # execute the map: write the new module directories refactor validate # gate: ask the engine whether it accepts what was written refactor diff # gate: the map and module directories on disk still match the source demonolith migrate # the state migration — needs an engine migrate map # pull read-only, back up, split into local state copies migrate prove # prove the split changes nothing (plans over the local copies) migrate run # seed each root's derived backend (guarded, never forced) migrate verify # judge the result against the real backends
Running a family command on its own runs its steps in order and pauses for approval before the step that commits: refactor pauses after it shows you the map, migrate pauses after the proof passes. Pass -y / --yes to approve automatically in CI. You can also run any subcommand on its own if you want to inspect one stage at a time.
refactor map, run, and diff don't need an engine. refactor validate and the whole migrate family do, and you name it explicitly with --engine terraform or --engine tofu (there's no default). Validate never needs credentials; it only contacts the provider registry. Migrate does, because it's about to run real plans. If you give the bare refactor an engine it runs the validate step too; if you don't, it skips validate and tells you how to run it before you commit.
Refactoring the code
The refactor family covers steps 2 through 5 of the manual procedure: find the seams, refactor the code, copy the backend over, review the result. It's all offline, and you can undo any of it with git. Like migrate, it runs as a sequence of subcommands, and each one writes a receipt. Running the whole family at once writes the new module directories under roots/ by default (--out to change):
karl@pc:~/.../sample$ demonolith refactor # runs map → run → validate → diff
NOTE running
demonlith refactoris the same as runningdemonolith refactor map,demonolith refactor run,demonolith refactor validateanddemonolith refactor diffin succession. Below we explain every step in detail, but recommend that you rundemonolith refactoras a bundle - it has built-in continuation gates between subcommands and asks for approval (can be skipped with the-yflag) before refactor runs.
refactor map reads the annotated monolith and writes out the plan without changing anything. It parses the code into a reference graph that determines where every annotated resoure needs to go, what it depends on (data, local, provider, required_providers and variable blocks copied from monolith, as well as variable blocks implied by resources it depends on that are moving elsewhere) and what depends on it (outputs it needs to surface).
Then it places each block where your annotations say (and unannotated ones into a catchall "legacy"), works out the deploy order, and figures out each module's backend and state location.
If the split would create a dependency cycle, it stops here and prints the cycle, so you find out before any state has moved.
What you get is map file that determines placement, deploy order, wiring edges, backend locations, and planned state moves.
karl@pc:~/.../sample$ demonolith refactor map Placement: app 7 resources/data cluster 3 resources/data database 3 resources/data legacy 2 resources/data networking 7 resources/data Catchall (legacy) holds 2 unannotated block(s): random_pet.backup_plan random_uuid.audit_log_bucket_id A dependency graph arises with the following deploy order: legacy networking cluster (depends on: networking) database (depends on: networking) app (depends on: cluster, database, networking) Planned module directories: app roots/app cluster roots/cluster database roots/database legacy roots/legacy networking roots/networking State locations (s3 backend, derived from my-states/sample.tfstate): app my-states/sample-app.tfstate cluster my-states/sample-cluster.tfstate database my-states/sample-database.tfstate legacy my-states/sample-legacy.tfstate networking my-states/sample-networking.tfstate Bootstrap module planned at roots/snapcd Receipt: demonolith-refactor-map.yaml (14 state moves, 9 cross edges)
refactor run executes the map. If the source code has changed since the map was written, it refuses to run. Otherwise it writes out the per-module directories. It preserves your formatting, turns every reference that crosses a module boundary into a variable/output pair and rewrites it to var.<input>, turns a cross-module depends_on into a whole-module ordering edge, carries the providers and locals through, writes each backend into the module's root.tf, removes the annotation comments, and finalizes the map.
This also means that you don't have to write the backends yourself. Demonolith carries the monolith's backend block into every new module directory and gives each one its own state location (prod/terraform.tfstate becomes prod/terraform-networking.tfstate, and so on). This works for every built-in backend type: local, s3, azurerm, gcs, consul, http, cos, oss, kubernetes, pg, and remote in workspace-name mode. Anything secret-shaped is kept out of the HCL and written to a per-module demono.env file instead (gitignored, mode 0600), which gets sourced around each init automatically. Each module directory also gets its own .gitignore, so you can commit it or move it into its own repo as-is.
karl@pc:~/.../sample$ demonolith refactor run Module directories written: app roots/app (4 files) cluster roots/cluster (5 files) database roots/database (5 files) legacy roots/legacy (3 files) networking roots/networking (4 files) snapcd roots/snapcd (Snap CD bootstrap) Receipt: demonolith-refactor-map.yaml (finalized)
refactor validate asks the engine whether it accepts every directory that was written. It runs tofu init -backend=false and tofu validate, which installs the providers, resolves references, and checks types. It never touches state or credentials.
karl@pc:~/.../sample$ demonolith refactor validate --engine tofu app: validating ... valid cluster: validating ... valid database: validating ... valid legacy: validating ... valid networking: validating ... valid snapcd-bootstrap: validating ... valid Valid: the engine accepts all 6 module directories.
refactor diff does the same job as the manual "review the refactor file by file" step. It re-runs the split from the current source and fails if the map or the written directories no longer match. Put it in CI and it re-checks the split after every change to the monolith.
karl@pc:~/.../sample$ demonolith refactor diff In sync: splitting the current source again would reproduce demonolith-refactor-map.yaml and the module directories exactly.
Migrating the state
The migrate family covers steps 6 through 10: split the state, copy the credentials and inputs, prove it offline, push, adopt. This is the part that touches real infrastructure, so it's the part you least want to do by hand.
karl@pc:~/.../sample$ demonolith migrate --engine tofu # map → prove → run → verify
NOTE running
demonolith migrateis the same as runningdemonolith migrate map,demonolith migrate prove,demonolith migrate run,demonolith refactor verifyin succession. Below we explain every step in detail, but we recommend that you rundemonolith migrateas a bundle - it has built-in continuation gates between subcommands and asks for approval (can be skipped with the-yflag) before migration runs.
migrate map pulls the monolithic state into a local file, backs it up, and runs state mv against that working copy in order to produce the new target states. It never modifies the monoliths original state file. The receipt records exactly what moves to where.
karl@pc:~/.../sample$ demonolith migrate map --engine tofu Splitting the state (moves from demonolith-refactor-map.yaml): moved module.storefront_dns -> app moved random_password.app_session_secret -> app moved random_pet.app_release -> app moved tls_private_key.deploy_signer -> app moved module.cluster -> cluster moved random_pet.node_pool -> cluster moved module.database -> database moved random_uuid.database_firewall_rule -> database moved module.private_subnet -> networking moved module.public_subnet -> networking moved random_pet.network_name -> networking moved random_uuid.nat_gateway_id -> networking moved random_uuid.vpc_id -> networking moved time_sleep.network_propagation -> networking Per-module state files written (local copies, nothing pushed yet): app roots/.demono/app.tfstate cluster roots/.demono/cluster.tfstate database roots/.demono/database.tfstate legacy roots/.demono/legacy.tfstate networking roots/.demono/networking.tfstate Backup: roots/.demono/monolith.demono-backup.tfstate Receipt: demonolith-migrate-map.yaml
migrate prove is the offline zero-changes check. It walks the new roots in dependency order, feeds each producer's outputs into its consumers' inputs (the job the control plane does at runtime), and plans each module against its local state copy. Every plan has to come back with zero changes; a create, a destroy, or an in-place update all fail the check. On the manual path this is the one check you run by hand on migration day. You can run it as often as you like; migrate run won't proceed until a proof passes that's no older than the map.
karl@pc:~/.../sample$ demonolith migrate prove --engine tofu Live reads (data sources are planned fresh; their answers must hold still): app data.http.oncall, data.http.platform, data.tls_public_key.deploy_key cluster data.http.oncall database data.http.platform networking data.http.platform Proving modules in dependency order (plans against the local state copies): legacy: proving ... zero changes networking: proving ... zero changes cluster: proving ... zero changes database: proving ... zero changes app: proving ... zero changes Root variable values written (demono.root.tfvars): app roots/app/demono.root.tfvars cluster roots/cluster/demono.root.tfvars database roots/database/demono.root.tfvars networking roots/networking/demono.root.tfvars ✓ 5 modules, each plans to zero changes with its real input values. Receipt: demonolith-migrate-prove.yaml
migrate run pushes each module's state to its final destination in the backend. The destination has to be empty or already hold this module's state, and if it already holds it the push is skipped (it checks whether the state files are byte-identical), so re-running a crashed migration is safe to repeat.
It won't overwrite anything by default. If the remote state file already exists but is stale sou can use --force to overwrite, and it warns you loudly when you do.
It never overwrites the monolith's original state! Retiring the monolith stays a manual decision you make later.
karl@pc:~/.../sample$ demonolith migrate run --engine tofu Backend credentials written to per-module demono.env files Pushing state to destinations (--force: non-matching existing state will be replaced): app: pushing to my-states/sample-app.tfstate (s3 backend) ... pushed cluster: pushing to my-states/sample-cluster.tfstate (s3 backend) ... pushed database: pushing to my-states/sample-database.tfstate (s3 backend) ... pushed legacy: pushing to my-states/sample-legacy.tfstate (s3 backend) ... pushed networking: pushing to my-states/sample-networking.tfstate (s3 backend) ... pushed Migration executed: app pushed my-states/sample-app.tfstate cluster pushed my-states/sample-cluster.tfstate database pushed my-states/sample-database.tfstate legacy pushed my-states/sample-legacy.tfstate networking pushed my-states/sample-networking.tfstate Receipt: demonolith-migrate-run.yaml Cross-module input values written (demono.graph.tfvars): app roots/app/demono.graph.tfvars cluster roots/cluster/demono.graph.tfvars database roots/database/demono.graph.tfvars Your original monolith state file remains untouched!
migrate verify runs the same check as prove, but against the real backends this time. It confirms each root's state landed where it should and plans clean. Nothing here refreshes: drift in the managed resources is out of scope, because migrating assumes the monolith already planned clean, and demonolith only proves the migration matches that starting point. Data sources are the one exception. Every plan reads them live, so their answers have to stay the same across the migration too.
karl@pc:~/.../sample$ demonolith migrate verify --engine tofu Backend credentials written to per-module demono.env files Live reads (data sources are planned fresh; their answers must hold still): app data.http.oncall, data.http.platform, data.tls_public_key.deploy_key cluster data.http.oncall database data.http.platform networking data.http.platform Verifying modules in dependency order (init + plan against the real backends): legacy: verifying ... zero changes networking: verifying ... zero changes cluster: verifying ... zero changes database: verifying ... zero changes app: verifying ... zero changes ✓ 5 modules, each plans to zero changes with its real input values. Receipt: demonolith-migrate-verify.yaml
A words on variable inputs
The migrate steps write each module's resolved values into two files: demono.root.tfvars (values for external variables that were already thus defined in the orginal monolith, written at prove) and demono.graph.tfvars (cross-module values, written at run).
For values that state can't give back, like expression-valued child-module outputs, demonolith fills them in from the values the proof already computed. This is the "copy the credentials and input values over" step from the manual path. The files load explicitly with -var-file, both during the proofs and any time you plan a module on its own.
One thing that demonilith cannot carry over into .tfvars files are values ever came from a -var flag on the original apply, since it isn't in the state to recover and cannot be read from the environment (no TF_VAR_* env vars for them), so you have to pass these explicitly with --var when you migrate).
For example:
`demonolith
karl@pc:~/.../sample$ demonolith migrate --engine tofu --var myvar=somevalue
Adopting the split into Snap CD
Once the monolith is split you have a new set of root modules, but they remain inter-dependent and the dependency graph Terraform used to walk for you now lives outside any single state. Something has to wire those cross-module dependencies together at runtime, on every deploy.
Demonolith can generate that wiring for you.
By default it writes out a Snap CD bootstrap (roots/snapcd, or --no-bootstrap to skip it) straight from the map: one snapcd_module per module, each cross edge as a snapcd_module_input_from_output. Applying that against a Snap CD server is the adoption step. From then on the control plane handles apply ordering, runs independent roots in parallel, and re-applies downstream modules when an upstream output changes, so you don't have to re-thread outputs by hand again. See Modular Deployments (below) for how the Module and Input system works.
The team story
Local dev
On a team the two halves land in different places. Developers usually have the code, so they can refactor, but state access is usually behind CI, so migrate isn't something they run locally.
A developer runs the refactor steps locally and opens a PR. The annotations, the map, and the new module directories are all just files. The PR is where you decide whether the proposed split looks right. What the PR can't tell you yet is whether the state migration behind it will work. That only comes out of migrate prove, which needs access to the state.
PR automated tests
In the PR's CI job, before it merges, it's worth running:
demonolith refactor diff, to check the committed split still matches the source. This confirms the map in the PR is the split that was actually done.demonolith refactor validate, the engine's acceptance check on the written directories. It needs an engine on the runner but no credentials.demonolith migrate mapanddemonolith migrate prove, a read-only rehearsal of the migration: plan every module to zero changes, push nothing. This needs the working-session inputs (backend credentials,TF_VAR_*,-varvalues) as CI secrets.
The once-off migration job
After the PR merges, run demonolith migrate --engine tofu once. It can be a manually triggered pipeline (workflow_dispatch), a person at a terminal, or a Snap CD Manual Job. Do it during a change freeze on the monolith so the window between prove and run stays short. It doesn't need a CI gate in front of it, since demonolith refuses a stale or out-of-sync map on its own, and if a run crashes you just run it again.
The last two steps stay manual:
- Apply the new root modules (e.g. as s Snap CD bootstrap) and watch every module go through a clean plan.
- Funally retire the monolith's pipelines and old state.
NOTE Don't wire the migration to run automatically on merge to
main. You split a monolith once. The migration is a one-time cutover, so making it a job that runs on every merge just leaves you with a job that has nothing to do.
All three lanes are runnable in the sample repo's GitHub Actions workflow: refactor diff and the read-only rehearsal on every PR, the migration behind a manual workflow_dispatch.
Try it
Both Demonolith and the sample are public and free to use. If you want to see the whole thing run before pointing it at your own code, clone sample-deployment-demonolith: it splits a deliberately messy monolith, proves the split inert, and migrates it, all locally, no cloud account needed.
The CLI itself lives at github.com/schrieksoft/demonolith. And once the monolith is split, Snap CD (source available at github.com/schrieksoft/snapcd is what runs the independent modules in dependency order and re-applies downstream when an upstream output changes, so you don't wire the graph back together by hand.
See also
- Splitting a Terraform Monolith — the ten-step manual procedure this tool automates
- The Problem with Large Terraform States — diagnosing when it's time to split
- Modular Deployments — how Snap CD manages cross-state dependencies after the split
- An Extensive Supporting Toolset — Demonolith and the rest of the Snap CD tooling
Intelligent GitOps for Infrastructure as Code. Automate, orchestrate, and scale your infrastructure deployments with confidence.
© 2026 Snap CD. All rights reserved.