The problem with adopting what already runs
Most companies do not start with Terraform. They start with a console, a deadline and a working product. Two years later production holds hundreds of resources that nobody can recreate from scratch, and the one engineer who remembers how they were made is on holiday.
You cannot simply write Terraform for these resources and apply it. Terraform would try to create duplicates, or fail on name conflicts. The right move is to import existing resources into Terraform, so state records that these objects already exist and the code describes them. It is one of the first things we do when moving a team off ClickOps, and a major part of our Terraform and infrastructure as code service. Our blog post on moving from ClickOps to Terraform on AWS covers the wider journey.
What good looks like
- Every important resource is in state and described by readable code.
- Imports were reviewed in pull requests, not run as private commands.
- The plan after import shows no changes.
- Imported resources are organised into modules and stacks, not dumped in one file.
- Nothing was recreated, so there was no downtime.
How it works in practice
Import blocks, reviewed like any change
An import block lives in your configuration. The plan shows the adoption before it happens:
import {
to = aws_db_instance.orders
id = "orders-prod"
}
resource "aws_db_instance" "orders" {
identifier = "orders-prod"
engine = "postgres"
instance_class = "db.m6g.large"
allocated_storage = 200
# ...remaining arguments written to match the live instance
lifecycle {
prevent_destroy = true
}
}
Run terraform plan. If the plan shows changes to aws_db_instance.orders, your code does not yet describe reality. Edit the code, not the database, until the plan reports only the import. Then apply, and remove the import block in a follow-up if you wish.
Generating a first draft
For a resource with many arguments, you can ask Terraform to draft the configuration:
terraform plan -generate-config-out=generated.tf
Treat the output as scaffolding. It is correct but noisy, includes defaults, and uses none of your conventions. We review it, remove noise, replace hard-coded values with variables, and move it into modules. For Azure, aztfexport can export resource groups into configuration and state, with the same caveat: tidy before you trust it.
Ordering the work
Resources have dependencies, and importing in the wrong order creates confusing plans. Our usual sequence:
- Networking: VPC or VNet, subnets, route tables, gateways.
- Identity: IAM roles and policies, managed identities, role assignments.
- Data: databases, buckets, storage accounts, key vaults.
- Compute and platform: clusters, instances, scale sets.
- Edge: load balancers, DNS, certificates.
Once the foundations are in code, they can be wrapped into reusable modules using moved blocks.
Decisions and trade-offs
Import or rebuild. For stateless resources that are cheap to recreate, such as a load balancer behind DNS, it can be quicker and cleaner to write fresh code and cut over. For databases and anything holding data, import is the safe path.
Hand-written versus generated code. Generated code is fast but long-winded. Hand-written code is slower but readable. We usually generate, then rewrite the important parts.
One big import or many small ones. Small batches keep every plan reviewable and failures contained. A single huge import is hard to review and hard to roll back.
When not to import. Resources owned by another system, for example load balancers created by a Kubernetes controller or resources managed by a vendor tool, should stay with their owner. Importing them creates a tug of war over the same object. For clusters themselves see Kubernetes consulting.
A worked example of one batch
Take a team with a hand-built production VPC. We start by listing the subnets, route tables, internet gateway and NAT gateways, and grouping them as one batch. Each object gets a resource block and an import block, and the first plan almost always shows differences: missing tags, a route written differently, an attribute the console set implicitly. We fix the code, not the network, and re-run the plan until it reports only the imports.
Before merging, a second engineer reads the plan with the live console open beside it. That review is where wrong addresses and forgotten route table associations surface. After the apply, the plan is run again and must be empty. Only then does the next batch begin. The slowest part is rarely the import itself: it is working out why the live setting differs from what anyone remembers choosing.
Tagging and ownership at the same time
Importing is a cheap moment to add consistent tags such as owner, environment and cost centre. Doing it inside the same pull request turns a one-off clean-up into a standing convention, and it makes later cost reports far easier to read.
Failure modes we avoid
- Importing without matching code. The next apply modifies production to match half-written code. We never apply without a clean plan.
- Forgotten sub-resources. Some cloud objects are separate resources in Terraform, such as bucket policies or security group rules. Missing one shows up as a perpetual diff.
- Import into the wrong address. A typo adopts a resource under the wrong name. Reviewing the plan catches this.
- No state backup. We snapshot the state before every batch, as described in our page on remote state.
- Sensitive values. Imported databases can write secrets into state, so backend access is restricted first.
Delivery and handover
You receive the imported resources in your repository, with modules where patterns repeat, a record of what was deliberately left out, and a list of resources we recommend deleting. Afterwards the drift checks keep it honest.
How to tell it is working
A plan against the imported stacks is empty. The number of resources in the cloud that are not in state is falling, and you can see that number. New environments are built from code rather than copied by hand. If you have an estate waiting to be adopted, get in touch.