Import Existing Infrastructure Into Terraform Without Downtime

Title: Import Existing Infrastructure Into Terraform Without Downtime
To import existing infrastructure into Terraform without downtime, use config-driven import blocks (Terraform 1.5 and later), run terraform plan to preview exactly what will change, then terraform apply. Import writes only to state, it never modifies, recreates or reboots the live resource. Before you touch anything, confirm your state backend, pin your provider versions, and check that the resource type is importable and how its ID is formatted.
This matters most right after a lift-and-shift, when servers, databases and networks exist in the account but nothing is under infrastructure as code. Adopting that estate into Terraform is how you stop the manual drift that a cloud migration leaves behind. The mechanics are safe if you understand what import does and, more importantly, what it does not do.
What import actually does, and what it does not
Import binds a live cloud object to a Terraform resource address in state. That is the whole operation. It does not create, replace, or reconfigure the resource, and it does not read your configuration back onto the object. The cloud API is the source of truth for the object; Terraform simply records that it now manages it.
The single rule that breaks people is one-to-one binding. Terraform expects each remote object to be bound to exactly one resource address, which is normally guaranteed because Terraform created everything itself. When you import a pre-existing estate you lose that guarantee, so if you import the same EC2 instance or S3 bucket into two addresses, Terraform can exhibit unwanted behaviour on the next apply. Track what you have imported.
There are two ways in. The older terraform import CLI command imports only into state and does not generate any configuration, so you write the resource block by hand first. The newer config-driven flow uses an import block that lets the operation be previewed during plan and executed during apply, which is what you want for anything you cannot afford to get wrong.
Check this before you change anything
Import failures are rarely destructive, but a sloppy setup wastes a day and shakes confidence. Clear these first.
- State backend and locking. Point at the real remote backend (S3, GCS, Azure Blob, or HCP Terraform) before importing, not a local
terraform.tfstateyou will later have to migrate. Import mutates state, so you want locking active. - Provider version pinned. Generated configuration and import ID formats track the provider schema. Pin the provider in
required_providersand runterraform initso plan output is reproducible. - Read-only credentials for discovery. You do not need write access to plan an import. Run discovery with read-only credentials so a stray apply cannot change anything while you are still mapping.
- Resource support and ID format. Not every resource is importable, and the import ID differs by resource type: an EC2 instance uses
i-abcd1234, a Route 53 zone uses its zone ID. On Google Cloud the ID is often a full path such asprojects/PROJECT_ID/global/networks/my-network. Check the provider's import section for each type before you write the block.
The config-driven workflow, step by step
Start with one resource you understand well. The pattern generalises once you trust it.
Write the import block
An import block needs the live resource ID and a to address, plus a matching (initially empty) resource block. The to argument must match the address of an existing resource block, and id is the cloud provider's ID, which must be known at plan time.
import {
to = aws_instance.app
id = "i-0abc123def4567890"
}
resource "aws_instance" "app" {
# filled in next step
}
Generate configuration, then prune it
Rather than hand-write every argument, let Terraform draft it. Run terraform plan with the -generate-config-out flag and a new file path and Terraform writes HCL for the imported resource.
terraform plan -generate-config-out=generated.tf
Two honest caveats. First, configuration generation is still experimental and the output format may change between minor versions, so treat it as a starting point, not a finished module. Second, the generated configuration contains every possible argument, including defaults and empty values, and HashiCorp recommends pruning it down to required arguments and anything that differs from the default. A raw generated file is verbose and often will not apply cleanly until you fix a few attribute types.
Plan until the diff is empty, then apply
This is the step that keeps the operation non-destructive. Run terraform plan and review it; if Terraform proposes unexpected changes, update the configuration until it matches the real resource, then terraform apply to import and update state. Your target state is "1 to import, 0 to add, 0 to change, 0 to destroy". If plan wants to change or replace the resource, your config does not yet match reality. Do not apply through that. Fix the drift on paper first.
After a clean apply, the import block is idempotent: re-running plan does not queue another import as long as the resource stays in state, so you can leave the block in as a record of origin or remove it.
Importing a whole estate, not one resource
Doing this one ID at a time across a migrated environment does not scale. Two approaches help.
for_each on the import block imports similar resources from a map without a separate block each. The for_each meta-argument instructs Terraform to import similar resources in one block:
locals {
buckets = {
staging = "acme-staging-assets"
prod = "acme-prod-assets"
}
}
import {
for_each = local.buckets
to = aws_s3_bucket.assets[each.key]
id = each.value
}
resource "aws_s3_bucket" "assets" {
for_each = local.buckets
}
For discovery at scale, Terraform now supports a query-based bulk import workflow where a list block retrieves resources from the provider so you can generate import configuration for large sets rather than hand-collecting IDs. It is newer and provider coverage varies, so verify support for your resource types before you build a pipeline around it. Google Cloud additionally offers a bulk export that lets you export resources as Terraform configuration and import Terraform state for those resources.
The trade-off, and what it costs later
Import is cheap to run and expensive to get subtly wrong. The cost lands later, as drift you did not model.
| Approach | Preview before state change | Generates config | Best for |
|---|---|---|---|
import block (config-driven) | Yes, in terraform plan | Yes, with -generate-config-out | Anything production, CI/CD, bulk adoption |
terraform import CLI | No, writes state immediately | No, you write the block first | One-off fixes, resources you already have config for |
The deeper trade-off is that importing declares "Terraform owns this now". Every attribute you leave out of config becomes a future diff. If the object was tuned by hand or by another tool (an autoscaler adjusting capacity, a controller writing tags), the next plan will try to revert those values. Two guards matter:
- Protect stateful resources during adoption. Set
prevent_destroyin alifecycleblock so Terraform rejects any operation that would destroy the resource. On a database or a stateful volume, this is the difference between a bad plan being blocked and a bad plan being applied. - Ignore what you do not manage. Use
ignore_changesfor attributes that a remote process legitimately modifies after creation, so Terraform stops fighting the autoscaler or the tagging controller.
resource "aws_instance" "app" {
# ...matched to the live instance...
lifecycle {
prevent_destroy = true
ignore_changes = [tags["LastScanned"]]
}
}
One structural limit worth planning around: provider configuration used for import cannot depend on non-variable inputs such as a data source, per the CLI import reference. Keep provider config simple for the modules you import into.
Done deliberately, importing turns a hand-built estate into a reviewable, versioned baseline. That is the foundation for the CI/CD and paved-road tooling our platform and DevOps engineering work is built on, and it is the step that makes a migration actually finished rather than merely cut over.
Frequently asked questions
Does importing a resource into Terraform cause downtime?
No. Import only writes an entry to Terraform state binding a live object to a resource address; it does not restart, recreate or reconfigure the object. Downtime only becomes a risk on the next apply if your configuration does not match the real resource and Terraform plans a replacement. That is exactly why the config-driven flow makes you review the plan before any state change.
What is the difference between the import block and terraform import?
The terraform import CLI command writes directly to state, generates no configuration, and gives you no preview, so you must author the resource block first. The import block is config-driven: it is planned during terraform plan, applied during terraform apply, can generate a starting configuration, and works inside CI/CD. For anything you cannot afford to break, use the import block.
Why does terraform plan want to change my resource after import?
Because your configuration does not yet match the live object. Import records the real object in state, and plan compares your HCL against it, so any argument you omitted or set differently shows up as a proposed change. Update the configuration until plan reports zero changes before you apply, and use ignore_changes for attributes that external processes modify.
Can I import a whole environment at once after a migration?
You can, but do it in reviewed batches, not one giant apply. Use for_each on import blocks to bring in similar resources from a map, and the query-based bulk workflow to discover resources at scale where your provider supports it. Import each remote object to exactly one resource address, and keep a record of what has been adopted so you never import the same object twice.