Every infrastructure team eventually inherits an account built through the console: a VPC someone created for a proof of concept, security groups edited during an incident, an RDS instance with settings nobody remembers choosing. Rebuilding it from scratch is risky and usually impossible without downtime. The safer path is to adopt the existing resources into Terraform exactly as they are, then improve them through normal code review.
Terraform's import block, together with config generation, makes that adoption repeatable and
reviewable. We used it to bring about 60 hand-built resources in a client's production account under
code. Here is the workflow and the parts the tooling does not do for you.
Import blocks versus the old import command
The terraform import CLI command attaches one existing resource to one address in state, immediately,
outside any plan. It works, but it changes state without review and requires you to write the matching
configuration by hand first.
An import block (Terraform 1.5 and later) is declared in code:
import {
to = aws_security_group.web
id = "sg-0a1b2c3d4e5f67890"
}
The import happens during apply, as part of a plan you can review. Combined with
terraform plan -generate-config-out=..., Terraform also writes a first draft of the resource
configuration for you. Since Terraform 1.7, import blocks support for_each, which helps with many
similar resources.
Step 1: inventory what exists
Start with a list of resources and their IDs. For AWS, Resource Explorer or service-specific CLI calls work; we used a short script per service:
aws ec2 describe-security-groups --query 'SecurityGroups[].[GroupId,GroupName,VpcId]' --output text
aws rds describe-db-instances --query 'DBInstances[].[DBInstanceIdentifier,Engine,DBInstanceClass]' --output text
Group resources by lifecycle into the state files they should live in, such as network, data and application. Adopting everything into one state file is quick and painful later.
Step 2: write import blocks only
In a new directory for the target state, write the provider configuration and only import blocks:
# imports.tf
import {
to = aws_vpc.main
id = "vpc-0f12ab34cd56ef789"
}
import {
to = aws_subnet.private["a"]
id = "subnet-0aa11bb22cc33dd44"
}
import {
to = aws_subnet.private["b"]
id = "subnet-0ee55ff66aa77bb88"
}
import {
to = aws_security_group.web
id = "sg-0a1b2c3d4e5f67890"
}
Step 3: generate the configuration
terraform init
terraform plan -generate-config-out=generated.tf
Terraform reads each resource from the provider and writes a resource block with every attribute it found. The plan output shows each resource as "will be imported" with no changes, if the generated code matches reality.
Config generation does not work for resource addresses that already have a configuration block, and it
does not support for_each keys in the to address. For the subnets above we generated config with
plain addresses first, then refactored to for_each with moved blocks.
Step 4: clean up the generated code
Generated configuration is accurate but not good code. It contains every attribute, including defaults and computed values, and no references between resources. Treat it as a draft. Our cleanup checklist:
- Remove attributes that equal provider defaults. They add noise and can cause drift when defaults change.
- Replace hard-coded IDs with references.
vpc_id = "vpc-0f12..."becomesvpc_id = aws_vpc.main.id. - Remove computed and conflicting attributes. Some generated attributes are read-only or conflict with others; the plan tells you which.
- Extract repeated values into locals or variables, such as CIDR blocks and tags.
- Split inline rules from security groups into separate rule resources if that is your standard, but be careful: changing how rules are managed is a change, not an import.
Before and after, for one security group:
# generated
resource "aws_security_group" "web" {
description = "launch-wizard-3 created 2023-04-11T10:22:31.455Z"
egress = [{ cidr_blocks = ["0.0.0.0/0"], description = "", from_port = 0,
ipv6_cidr_blocks = [], prefix_list_ids = [], protocol = "-1",
security_groups = [], self = false, to_port = 0 }]
name = "launch-wizard-3"
name_prefix = null
revoke_rules_on_delete = null
tags = {}
tags_all = {}
vpc_id = "vpc-0f12ab34cd56ef789"
}
# cleaned
resource "aws_security_group" "web" {
name = "launch-wizard-3" # renaming forces replacement; keep for now
description = "launch-wizard-3 created 2023-04-11T10:22:31.455Z"
vpc_id = aws_vpc.main.id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
The ugly name and description stay. Changing either forces the security group to be replaced, which is a separate, planned change.
Step 5: prove it with a zero-change plan
After cleanup, the plan must show only imports:
Plan: 4 to import, 0 to add, 0 to change, 0 to destroy.
Any "to change" means the code does not match reality. Fix the code, not the resource, until the plan is clean. This is the property that makes adoption safe: merging the pull request changes nothing in AWS.
Then apply. Terraform records the resources in state, and the import blocks can be deleted in a follow-up commit, or left in place; they are ignored once the resource is in state.
Resources that need care
- Databases and other stateful resources. Add
lifecycle { prevent_destroy = true }before the first apply, so a later mistake cannot delete them. - Secrets in attributes. Generated config can include sensitive values such as initial passwords.
Remove them and switch to a managed secret or
ignore_changes. - Resources with sub-resources. Some services split configuration across several Terraform resource types (S3 bucket versioning, encryption and policy are separate resources). Import each part.
- Attributes Terraform cannot read back, such as some passwords. The plan will want to change them;
use
ignore_changesfor those attributes.
Common mistakes
| Mistake | Effect | Fix |
|---|---|---|
| Applying generated code without review | Defaults and odd values become "intent" | Clean up first, prove with a zero-change plan |
| One giant state for everything | Slow plans, big blast radius | Group by lifecycle into several states |
| Fixing drift during import | Import PR also changes production | Import as-is; change in a later PR |
Forgetting prevent_destroy on data |
A refactor can delete a database | Add it before the first apply |
| Leaving console edit rights in place | New drift appears next week | Restrict console write access after adoption |
After the import
Adoption is only useful if the console stops being the way changes happen. Once a group of resources was
under Terraform, we removed write permissions for those services from human roles, kept read access for
debugging, and added a nightly terraform plan to detect drift. Changes since then go through pull
requests like any other code.
Key takeaways
- Import blocks make adopting existing resources a reviewed, planned change instead of a state edit.
-generate-config-outwrites a first draft; treat it as raw material and clean it up.- A plan with only imports and zero changes is the proof that adoption is safe.
- Protect stateful resources with
prevent_destroybefore the first apply. - Lock down console write access afterwards, or drift returns.
References
- Terraform import block: https://developer.hashicorp.com/terraform/language/import
- Generating configuration: https://developer.hashicorp.com/terraform/language/import/generating-configuration
- Refactoring with moved blocks: https://developer.hashicorp.com/terraform/language/modules/develop/refactoring
- AWS Resource Explorer: https://docs.aws.amazon.com/resource-explorer/latest/userguide/welcome.html
Comments
0No comments yet. Be the first to comment!
Sign In to post a comment.