Import Existing Infrastructure Into Terraform Without Downtime

Illustration from developer.hashicorp.com
Illustration from developer.hashicorp.com

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.tfstate you 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_providers and run terraform init so 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 as projects/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.

ApproachPreview before state changeGenerates configBest for
import block (config-driven)Yes, in terraform planYes, with -generate-config-outAnything production, CI/CD, bulk adoption
terraform import CLINo, writes state immediatelyNo, you write the block firstOne-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:

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.

Working on something like this?

Tell us what you are building and we will give you an honest read on it.