// blog

Adopting Terraform That Already Exists and Is Wrong

Most IaC guides assume a blank slate. Real teams have Terraform that half-works and that everyone is afraid to touch. Here is how to recover it in place, without the rewrite.

Most infrastructure as code tutorials start from an empty directory. terraform init, a clean module structure, best practices from day one.

That is not the situation we usually walk into. The usual situation is: there is a Terraform codebase, it was written two or three years ago, under deadline, by people who are no longer around. Nobody owns it or understands the intention behind it anymore. terraform plan produces 47 changes nobody can explain, and the team has quietly stopped running it. What was a “just this once” change in the console became the new practice. The code is still in the repo, describing a situation that is no longer true.

This is more common than greenfield adoption, and it is a different problem. Here is how we approach it.

First decision: rewrite or recover?

Before touching anything, decide which path you are on. There are two, and all the work that follows depends on this choice.

A rewrite is viable when the codebase is small enough to redo in days, when the code covers only a fraction of what is actually running, or when state is corrupted or lost and you would be importing everything anyway. In those cases, starting over is easiest: write the structure you want, import the resources into it, and retire the old code in one motion.

Incremental recovery is the right call for everything else, which in practice is most cases. A rewrite of a substantial codebase means running two sources of truth in parallel for months, importing or recreating every resource into new code while the old code rots further and changes keep accumulating outside of Terraform. Half-finished infrastructure rewrites leave production split across two codebases with unclear ownership. If the existing code covers most of production and the state is usable, recover in place, with production running the whole time.

The test: estimate the rewrite including every import, not just the code writing. If that number is measured in days, rewrite. If it is measured in weeks or months, recover. The rest of this post is about the recovery path.

Step 1: Find out what is actually true

Before changing anything, establish facts. Three questions:

Does it plan? Run terraform plan in every root module. Broken providers, missing state, credential errors. Failures here show which parts of the codebase nobody has run in a long time.

What does the plan say? A noisy plan is a map of the divergence. Sort the proposed changes into three buckets, because each gets a different treatment:

  • Drift. Console changes that stuck. The resized database, the added security group rule. These need codifying.
  • Rot. Code describing resources that no longer exist. These need removing from code and state.
  • Noise. Perpetual diffs from provider quirks, ordering, or bad expressions. These need fixing in the code so they stop appearing.

What is not in code at all? List what is actually running in the account and compare it with state. Anything in the account and absent from state is unmanaged. You do not need to fix that now. You need the inventory, because unmanaged resources will surprise you.

Convert the findings into actionable tasks: which drift to codify, which code to remove, and what target structure to work toward.

The assessment usually takes a few days, and the results rarely match what the team expected.

Step 2: Make plans clean before making code good

The single most important milestone is a clean terraform plan on every root module. Not good code. Clean plans. Until plans are clean, nobody can review changes meaningfully, because every diff is buried under the same 47 changes of standing noise, and nobody trusts apply, so nobody runs it, so the drift compounds.

Getting there is mechanical work:

  • Bring Terraform and provider versions up to date first. Abandoned codebases are usually pinned to old versions. Upgrade in steps, read the changelogs, and get back to a clean plan after each jump, because provider major versions introduce their own plan changes and you do not want those mixed into the drift cleanup. This also unlocks the tooling the rest of the recovery relies on: moved blocks need Terraform 1.1, import blocks 1.5, removed blocks 1.7. Upgrading is not always possible, and staying on an old version can be a deliberate decision. If you make that call, document it and adjust the recovery to the tooling you actually have. terraform state mv and friends cover everything the newer blocks do, the difference is that they run outside of review.
  • Codify the drift you want to keep. The RDS instance someone resized in the console stays resized. Update the code to match reality. Sometimes reverting is the saner option. An overly permissive security group rule should be reverted, not codified. Either way, make it a deliberate per-change decision, not a default.
  • Remove the rot. Resources that no longer exist get removed from code and state. Use terraform state rm and delete the code, or removed blocks on recent Terraform versions.
  • Silence the noise. Perpetual diffs from ordering, casing, or provider defaults get fixed with explicit values or, sparingly, lifecycle ignore_changes. Avoid ignore_changes for anything that can be remediated in code, because each one hides a real difference between code and infrastructure, and they accumulate.

Do all of this in changes that alter no infrastructure. The plan shows code and state moving, the apply changes nothing in the account. These changes are safe to review and safe to merge, which is exactly what a burned team needs to rebuild the habit of running Terraform at all. This is not a one-pass job. Work through it in small batches until every root module produces a clean plan.

Step 3: Stop new manual changes

Clean plans are the moment to change how the team works, because from here on, manual changes are the thing that puts you back where you started. Announcing this policy earlier does not stick. While plans are broken, the console is the only reliable way to ship a change, and a rule nobody can follow gets ignored. Now the pipeline can carry the work, so make it explicit policy: every future change and adjustment to managed infrastructure goes through the IaC process. Write it down, announce it, get the leads to back it.

Then make the pipeline the easiest way to ship a change.

  • Run plan on every pull request and apply on merge.
  • Keep state in a backend with locking.
  • Restrict console write access, read-only for most of the team is usually achievable.
  • Emergencies will still happen, and someone will touch the console during one. Agree in advance how that gets handled. The manual change gets reconciled into code within a week, no exceptions.

This is behavior change more than tooling, and it is the part that determines whether the recovery sticks. There is no point improving the code while manual changes continue, the drift just comes back.

Step 4: Restructure only what changes

Now the code can get better, but with a rule: refactoring effort follows the rate of change, not the level of ugliness.

That 800 line file defining the VPC is ugly and it works and nobody has touched it in two years. Leave it. The service definitions that change weekly and require copy-pasting 90 lines each time, that is where restructuring pays.

In practice this means improving as you go. When regular work takes you into some logical unit, improve that unit while you are there and leave it better than you found it.

Practical notes for this phase:

  • Use moved, removed, and import blocks, not state surgery. These three cover the whole lifecycle of restructuring: moved for renames and moves into modules, removed for dropping resources from management, import for bringing unmanaged ones in. All of them express the change in code, which means it goes through review and CI like everything else, instead of living in someone’s shell history as a sequence of terraform state commands.
  • Only extract modules where repetition already exists. Three near-identical service blocks are worth turning into a module, ported one at a time with moved blocks. A resource that exists once stays inline.
  • Split big state files. Smaller states mean faster plans and a smaller blast radius when something goes wrong. The usual split is networking (VPC, subnets, the things that rarely change), data stores (databases and anything else you cannot afford to recreate), and services (the app layer that changes all the time). One at a time, clean plans before and after.
  • Import unmanaged resources opportunistically. When work touches an unmanaged resource, import it then, with import blocks so it goes through review. A dedicated project to import everything at once tends to drag on and gives everyone a reason to postpone the actual work. Importing as part of real work keeps coverage growing without anyone blocking on it.

What this looks like in practice

How long this takes depends on the codebase, and it will compete with regular work at times, so expect some friction. The milestones are the same in every case. Plans go clean, applies resume, manual changes stop, and from that point on every change is recorded in the git history, reviewed and traceable. The team ends up owning the codebase again.

If you have a Terraform codebase nobody wants to touch, it can be recovered, and recovering it costs a lot less than the rewrite you have been putting off. Get in touch and we will take a look at what you have.