Importing existing infrastructure into Terraform state
Bring click-ops resources under Terraform with import blocks and generated config — without recreating them.
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 {to = aws_s3_bucket.app_dataid = "acme-app-data-prod" # the provider's import id: bucket name for S3}import {to = aws_s3_bucket_versioning.app_dataid = "acme-app-data-prod"}# providers that define a resource identity accept the identity instead of an opaque idimport {to = aws_s3_bucket.assetsidentity = {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.
terraform plan -generate-config-out=generated.tfaws_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.tf58 41forty-one attributes for two resources; most are defaults read back. Edit before the next stepterraform plan # after moving the blocks into storage.tf and trimming themPlan: 2 to import, 0 to add, 0 to change, 0 to destroy.terraform apply -auto-approve && terraform planApply 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.
When the plan says replace, stop
What a non-empty plan after import means
| Plan line | Meaning | Action |
|---|---|---|
~ update in-place on a tag or a description | the configuration expresses intent the object did not have | usually fine; confirm it is the intended difference |
~ update in-place on encryption, public access, deletion protection | the generated draft was trimmed too far, or a default changed | fix the configuration; do not apply a security change by accident |
-/+ must be replaced | an 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 resource | a resource the provider models separately was never imported | add 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.
# adopted flat, then refactored into the storage module without replacementmoved {from = aws_s3_bucket.app_datato = module.storage.aws_s3_bucket.this}
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.