Importing existing infrastructure into Terraform state

Bring click-ops resources under Terraform with import blocks and generated config — without recreating them.

Sep 10, 2024·Updated ·6 min readIntermediate·By SecOpsLog · documentation-verified

Terraform not knowing about a bucket created in the console in 2022 does not make the bucket less important; it makes it more dangerous, because the next person who writes an aws_s3_bucket with the same name gets a conflict at apply, and the one after that deletes it "to fix the conflict". Import is how Terraform learns that a real object is the one a resource block describes, and since 1.5 the mapping is written in configuration rather than typed into a command, which makes it reviewable and repeatable. The procedure has one rule that matters more than the syntax: the resource is under management when terraform plan reports nothing to do, and not before.

Declare the mapping in code

import.tf
import {
to = aws_s3_bucket.app_data
id = "acme-app-data-prod" # the provider's import id: bucket name for S3
}
import {
to = aws_s3_bucket_versioning.app_data
id = "acme-app-data-prod"
}
# providers that define a resource identity accept the identity instead of an opaque id
import {
to = aws_s3_bucket.assets
identity = {
account_id = "111122223333"
region = "eu-west-1"
bucket = "acme-assets-prod"
}
}

Each import block names a resource address and the object it should own. The id is in whatever format the provider documents for that resource type (a bucket name, sg-0a1b2c3d, an ARN for some services), and for resources whose provider defines a resource identity the identity block replaces the guesswork with named attributes. Terraform validates the mapping during plan, so a wrong id fails there, and it writes state only at apply. An S3 bucket is several resources in the AWS provider (the bucket, its versioning, its encryption configuration, its public access block), and each needs its own block; importing the bucket alone leaves the others undeclared and the next plan proposes to remove what it cannot see.

Generate the configuration, then edit it

With only import blocks in place, terraform plan -generate-config-out=generated.tf reads each remote object and writes a resource block that reproduces its current attributes. The generated file is a draft: it contains every attribute the provider read back, including ones you would never set by hand, and it knows nothing about your variables, tags policy or lifecycle intentions. Move each block into the module where it belongs, replace literals with the variables the rest of the stack uses, delete the attributes that should be left at provider defaults, and keep the file out of version control until it has been through that edit.

bash — generate, edit, then plan until it is quiet
terraform plan -generate-config-out=generated.tf
aws_s3_bucket.app_data: Preparing import... [id=acme-app-data-prod]
aws_s3_bucket.app_data: Refreshing state... [id=acme-app-data-prod]
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
Terraform has generated configuration and written it to "generated.tf". Please review …
wc -l generated.tf && grep -c "^ [a-z_]* *=" generated.tf
58 41
forty-one attributes for two resources; most are defaults read back. Edit before the next step
terraform plan # after moving the blocks into storage.tf and trimming them
Plan: 2 to import, 0 to add, 0 to change, 0 to destroy.
terraform apply -auto-approve && terraform plan
Apply complete! Resources: 2 imported, 0 added, 0 changed, 0 destroyed.
No changes. Your infrastructure matches the configuration.

The two plans in that sequence are the control. The first, after editing, must still say to import with no change and no destroy; a change means the trimmed configuration differs from the live object in a way Terraform would correct at apply, and that correction is an infrastructure change made under the label of an import. The second, after apply, must be empty. Only then is the import block removable, in a later commit, because leaving it is harmless and removing it too early is a way to lose the record of what was imported when.

Import changes state; apply changes infrastructure
The import itself never touches the cloud object. The apply that follows it can, if the configuration and the object disagree. For anything customer-facing, take the object’s current settings (an AWS Config snapshot, or the describe output saved to the ticket) before the first apply, and run the plan in the account with read-only credentials first so a wrong id cannot become a wrong apply.

When the plan says replace, stop

What a non-empty plan after import means

Plan lineMeaningAction
~ update in-place on a tag or a descriptionthe configuration expresses intent the object did not haveusually fine; confirm it is the intended difference
~ update in-place on encryption, public access, deletion protectionthe generated draft was trimmed too far, or a default changedfix the configuration; do not apply a security change by accident
-/+ must be replacedan attribute that forces replacement differs (name, engine, AZ, an immutable setting)stop; align the configuration to the object, or accept a planned migration under a window
- destroy of a child resourcea resource the provider models separately was never importedadd the missing import block and plan again

Replacement is the failure mode import exists to avoid. When the plan shows it, the fix is in the configuration, and the diff to read is between the live object (aws s3api get-bucket-…, describe-…) and the HCL, attribute by attribute. Where a resource has to move into a module or be renamed as part of adoption, a moved block records the rename so Terraform updates the address in state instead of destroying one resource and creating another.

moves.tf
# adopted flat, then refactored into the storage module without replacement
moved {
from = aws_s3_bucket.app_data
to = module.storage.aws_s3_bucket.this
}
Import vs replace
Import when
The object must keep its identity (data, DNS, ARNs in other systems)
Its configuration is close to what you would write
Adoption is gradual, one service at a time
Replace when
The object is misconfigured in ways that force replacement anyway
A tested module already produces the right shape
A maintenance window exists and the data can move

Adopted resources carry the history they were created with, which is often the reason they were not in Terraform: a public ACL, a missing encryption setting, an open security group. Running a policy scan on the trimmed configuration turns that history into findings before the first apply, and a scheduled terraform plan in CI that must stay empty is how you learn that someone is still editing the object in the console.

Go deeper in a courseTerraformImport and moved blocks, state operations, modules and CI apply pipelines.View course

Related posts

Quick reference