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 resource or module block 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, default legacy). This is the same "whatever's left" module you'd end up with when splitting the state by hand.
  • Don't annotate data blocks. 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):

shell
karl@pc:~/.../sample$ demonolith refactor    # runs map → run → validate → diff

NOTE running demonlith refactor is the same as running demonolith refactor map, demonolith refactor run, demonolith refactor validate and demonolith refactor diff in succession. Below we explain every step in detail, but recommend that you run demonolith refactor as a bundle - it has built-in continuation gates between subcommands and asks for approval (can be skipped with the -y flag) 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.

shell
karl@pc:~/.../sample$ demonolith refactor map

── 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.

shell
karl@pc:~/.../sample$ demonolith refactor run

── 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.

shell
karl@pc:~/.../sample$ demonolith refactor validate --engine tofu

── refactor validate ──

  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.

shell
karl@pc:~/.../sample$ demonolith refactor diff

── 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.

shell
karl@pc:~/.../sample$ demonolith migrate --engine tofu    # map → prove → run → verify

NOTE running demonolith migrate is the same as running demonolith migrate map, demonolith migrate prove, demonolith migrate run, demonolith refactor verify in succession. Below we explain every step in detail, but we recommend that you run demonolith migrate as a bundle - it has built-in continuation gates between subcommands and asks for approval (can be skipped with the -y flag) 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.

shell
karl@pc:~/.../sample$ demonolith migrate map --engine tofu

── migrate map ──

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.

shell
karl@pc:~/.../sample$ demonolith migrate prove --engine tofu

── migrate prove ──

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.

shell
karl@pc:~/.../sample$ demonolith migrate run --engine tofu

── migrate run ──

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.

shell
karl@pc:~/.../sample$ demonolith migrate verify --engine tofu

── migrate verify ──

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 map and demonolith 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_*, -var values) 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

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 🗙