Overview and What You Will Learn
You joined a company where the entire AWS account was built by clicking in the console over the past two years. There are EC2 instances, VPCs, RDS databases, IAM roles, and S3 buckets — none of it managed by Terraform. The team wants to adopt IaC without destroying and recreating hundreds of production resources.
terraform import is how you bring existing infrastructure under Terraform management without touching the real resources. You write the HCL configuration, run the import command, and Terraform adds the resource to its state file. From that moment on, all future changes go through Terraform.
By the end of this lab you will be able to:
- Import any AWS resource into Terraform state using the CLI import command
- Use the new declarative import block (Terraform 1.5+) for cleaner workflows
- Auto-generate HCL from existing resources using
-generate-config-out - Fix plan mismatches after import until Terraform shows no changes
- Handle the most common import errors and gotchas
Why This Matters in Production
At PhonePe, when the platform team decided to adopt Terraform, they had 400+ AWS resources created manually over three years. Destroying and recreating everything was not an option — these were live production systems handling millions of payments per day.
They used terraform import to bring resources under management one service at a time, starting with the least critical. Each import took 15 minutes — write HCL, import, fix the plan diff, commit. In six weeks, the entire infrastructure was Terraform-managed. The next time a new environment was needed, they applied the existing code and had it running in 20 minutes instead of three days.
Core Principles
The Import Flow
+------------------------------------------+| Existing AWS resource (not in state) || EC2: i-0a1b2c3d4e5f6789 || Created manually in AWS console |+------------------------------------------+ | Step 1: Write resource block in HCL | v+------------------------------------------+| resource "aws_instance" "payments_api" { || ami = "ami-0f5ee92e..." || instance_type = "t3.large" || } |+------------------------------------------+ | Step 2: terraform import | v+------------------------------------------+| terraform.tfstate now contains: || aws_instance.payments_api: || id = "i-0a1b2c3d4e5f6789" || all real attributes read from AWS |+------------------------------------------+ | Step 3: terraform plan — fix mismatches | v+------------------------------------------+| No changes. Your infrastructure matches || the configuration. || Resource is now fully Terraform-managed |+------------------------------------------+Detailed Step-by-Step Practical Lab
Step 1 — Write the Resource Configuration
Before running import, write the resource block in your .tf files. You do not need every argument — just enough for Terraform to accept the block syntax. You will fix the rest after import:
# main.tf — write a skeleton resource block first # For an EC2 instanceresource "aws_instance" "payments_api" { ami = "ami-0f5ee92e2d63afc18" # put the real AMI — find it in the console instance_type = "t3.large" # put the real type — find it in the console # More arguments will be added after import when you see the plan diff} # For an S3 bucketresource "aws_s3_bucket" "customer_data" { bucket = "phonepay-customer-data-prod" # exact real bucket name} # For a security groupresource "aws_security_group" "payments_api" { name = "phonepay-payments-api-sg" vpc_id = "vpc-0a1b2c3d4e5f" # real VPC ID}Step 2 — Find the Resource ID
Every AWS resource has an ID that Terraform uses to locate it. The format varies by resource type:
# EC2 Instance — ID starts with i-aws ec2 describe-instances \ --filters "Name=tag:Name,Values=phonepay-payments-api-prod" \ --query "Reservations[0].Instances[0].InstanceId" \ --output text# i-0a1b2c3d4e5f6789 # S3 Bucket — ID is the bucket name# Just use the bucket name directly # Security Group — ID starts with sg-aws ec2 describe-security-groups \ --filters "Name=group-name,Values=phonepay-payments-api-sg" \ --query "SecurityGroups[0].GroupId" \ --output text# sg-0a1b2c3d4e5f # RDS Instance — ID is the DB identifieraws rds describe-db-instances \ --query "DBInstances[0].DBInstanceIdentifier" \ --output text# phonepay-payments-postgres-prod # IAM Role — ID is the role nameaws iam list-roles \ --query "Roles[?RoleName=='phonepay-payments-api-role'].RoleName" \ --output text# phonepay-payments-api-role # VPC — ID starts with vpc-aws ec2 describe-vpcs \ --filters "Name=tag:Name,Values=phonepay-prod-vpc" \ --query "Vpcs[0].VpcId" \ --output text# vpc-0a1b2c3d4e5fStep 3 — Run terraform import
# Syntax: terraform import <resource_type>.<resource_name> <resource_id> # Import EC2 instanceterraform import aws_instance.payments_api i-0a1b2c3d4e5f6789# aws_instance.payments_api: Importing from ID "i-0a1b2c3d4e5f6789"...# aws_instance.payments_api: Import prepared!# Prepared aws_instance for import# aws_instance.payments_api: Refreshing state... [id=i-0a1b2c3d4e5f6789]## Import successful!# The resources that were imported are shown above. These resources are now in# your Terraform state and will henceforth be managed by Terraform. # Import S3 bucketterraform import aws_s3_bucket.customer_data phonepay-customer-data-prod # Import security groupterraform import aws_security_group.payments_api sg-0a1b2c3d4e5f # Import RDS instanceterraform import aws_db_instance.main phonepay-payments-postgres-prod # Import IAM roleterraform import aws_iam_role.payments_api phonepay-payments-api-role # Import VPCterraform import aws_vpc.main vpc-0a1b2c3d4e5f # Import a subnet — needs both the subnet ID as the resource IDterraform import aws_subnet.private_1 subnet-0a1b2c3d4e5f # Import an S3 bucket policy — needs bucket name as IDterraform import aws_s3_bucket_policy.customer_data phonepay-customer-data-prodStep 4 — Fix Plan Mismatches
After import, run terraform plan. It will almost always show differences between your skeleton HCL and the real resource. Your job is to update the HCL until the plan shows no changes:
terraform plan # The plan will show changes like:## ~ aws_instance.payments_api will be updated in-place# ~ ebs_optimized = false -> true <- real instance has this on# ~ monitoring = false -> true <- real instance has monitoring# ~ iam_instance_profile = null -> "phonepay-payments-api-profile"# ~ metadata_options {# ~ http_tokens = "optional" -> "required" <- IMDSv2 is required# }## Fix: update your resource block to match every ~ itemUpdate your HCL to match:
resource "aws_instance" "payments_api" { ami = "ami-0f5ee92e2d63afc18" instance_type = "t3.large" ebs_optimized = true # added monitoring = true # added iam_instance_profile = "phonepay-payments-api-profile" # added metadata_options { http_endpoint = "enabled" http_tokens = "required" # added http_put_response_hop_limit = 1 } tags = { Name = "phonepay-payments-api-prod" Environment = "prod" ManagedBy = "terraform" }}Repeat terraform plan → fix differences → terraform plan until:
terraform plan# No changes. Your infrastructure matches the configuration.This is the goal. When the plan is clean, the resource is fully managed by Terraform.
Step 5 — Import S3 Sub-Resources
S3 buckets have many sub-resources in the AWS provider. After importing the bucket itself, import each sub-resource separately:
# Import the bucketterraform import aws_s3_bucket.customer_data phonepay-customer-data-prod # Import public access block — ID is the bucket nameterraform import aws_s3_bucket_public_access_block.customer_data phonepay-customer-data-prod # Import versioning — ID is the bucket nameterraform import aws_s3_bucket_versioning.customer_data phonepay-customer-data-prod # Import encryption configuration — ID is the bucket nameterraform import aws_s3_bucket_server_side_encryption_configuration.customer_data \ phonepay-customer-data-prodStep 6 — The Declarative Import Block (Terraform 1.5+)
Terraform 1.5 introduced import blocks that live in your .tf files. Instead of running a CLI command, import happens as part of terraform apply:
# imports.tf — declarative import configuration import { to = aws_instance.payments_api id = "i-0a1b2c3d4e5f6789"} import { to = aws_s3_bucket.customer_data id = "phonepay-customer-data-prod"} import { to = aws_security_group.payments_api id = "sg-0a1b2c3d4e5f"}# With import blocks, terraform plan shows what will be importedterraform plan# aws_instance.payments_api will be imported# aws_s3_bucket.customer_data will be imported# aws_security_group.payments_api will be imported # Apply performs the importsterraform apply # After apply, remove the import blocks — they are only needed once# The resources are now in state and import blocks are no longer requiredStep 7 — Auto-Generate HCL with -generate-config-out (Terraform 1.5+)
If you have many resources to import and do not want to write skeleton HCL manually, Terraform 1.5 can generate it for you:
# Step 1: Write import blocks WITHOUT any resource blocks# imports.tfimport { to = aws_instance.payments_api id = "i-0a1b2c3d4e5f6789"}# Step 2: Run plan with -generate-config-outterraform plan -generate-config-out=generated.tf # Terraform reads the real EC2 instance and writes HCL to generated.tf# The generated file will look like:# resource "aws_instance" "payments_api" {# ami = "ami-0f5ee92e2d63afc18"# instance_type = "t3.large"# ebs_optimized = true# monitoring = true# ... every single attribute ...# }# Step 3: Review and clean generated.tf# Remove computed-only attributes like id, arn, private_dns# Remove attributes you want Terraform to manage dynamically (like tags from variables)# Keep only the attributes that define the desired configuration # Step 4: Run plan until cleanterraform plan# Fix remaining mismatches, then applyterraform applyProduction Best Practices and Common Pitfalls
Import one service at a time, not all at once. Importing 200 resources in a single session is overwhelming. Start with one service — say, the payments API EC2 instances. Get the plan clean. Commit and apply. Then move to the next service. Each increment is manageable and reviewable.
Run
terraform state showto understand what was imported. After each import, runterraform state show <resource>to see every attribute Terraform read from the real resource. Use this as the reference for updating your HCL to match.Expect the plan to show changes after every import. Your skeleton HCL will never perfectly match reality on the first try. Multiple rounds of plan → fix → plan is normal. The goal is getting to "No changes" — not getting it right on the first attempt.
Remove import blocks after apply. Import blocks in
.tffiles are only needed once. Leave them in and Terraform will try to import on every apply (it will fail on subsequent runs since the resource is already in state). Remove them after the first successful apply.Never import a resource that is already in state. Running import on a resource that Terraform already tracks causes an error. Always check
terraform state listbefore importing.Some resource attributes cannot be imported. Certain attributes are write-only in the AWS provider — like initial database passwords. After import, Terraform may show them as unknown. These need to be set with
ignore_changeslifecycle rules or managed separately.
Quick Reference and Troubleshooting Commands
| Command | What It Does |
|---|---|
terraform import <addr> <id> |
Import a resource into state by its real-world ID |
terraform state show <addr> |
Show all attributes of an imported resource — use to update HCL |
terraform state list |
Verify which resources are already in state |
terraform plan -generate-config-out=file.tf |
Auto-generate HCL from import blocks (Terraform 1.5+) |
terraform state rm <addr> |
Remove from state if you imported the wrong resource |
| Error | Root Cause | Fix |
|---|---|---|
Resource already managed by Terraform |
Already in state | Check terraform state list — already imported |
Cannot import non-existent remote object |
Wrong resource ID | Verify the ID in AWS console or CLI |
Error: Resource type does not support import |
Provider limitation | Check provider docs — not all resources support import |
InvalidInstanceID.NotFound |
Wrong EC2 ID format | Must be i- followed by hex characters |
Plan shows -/+ after import |
HCL triggers a replacement | Read (forces replacement) note — some attributes cannot be changed in-place |
| Generated HCL causes errors | Auto-generated code has computed-only attributes | Remove id, arn, private_dns and other computed fields |