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..." becomes vpc_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_changes for 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-out writes 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_destroy before the first apply.
  • Lock down console write access afterwards, or drift returns.

References