Questions
Your company has 200 manually-created EC2 instances. Import them into Terraform without downtime.
The Scenario
Your company has 200 EC2 instances that were created manually over 3 years. Management wants everything in Terraform for consistency. Current state:
# Random naming, no tags, various configurations
$ aws ec2 describe-instances --query 'Reservations[].Instances[].{ID:InstanceId,Name:Tags[?Key==`Name`].Value|[0]}' --output table
| InstanceId | Name |
|--------------------|-------------------|
| i-0abc123def456789 | web-server-1 |
| i-0def456789abc012 | (none) |
| i-0789abc012def345 | prod-api |
| ... 197 more ... | |
Constraints:
- Zero downtime - these are production servers
- Must match current configuration exactly
- Need to establish naming conventions going forward
The Challenge
Design and execute an import strategy that brings all resources under Terraform management without any service disruption.
A junior engineer might try to import all 200 instances at once, write Terraform config from scratch without checking actual AWS settings, or skip verification steps. This risks configuration drift that triggers unwanted changes, missed resources, or state corruption.
A senior engineer uses a systematic approach: inventory resources, generate Terraform config that matches current state, import in batches, verify with targeted plans, and iterate. They use tools like terraformer or AWS-to-Terraform generators to bootstrap config, then refine manually.
Phase 1: Inventory and Assessment
# Export all EC2 instances to JSON for analysis
aws ec2 describe-instances \
--query 'Reservations[].Instances[]' \
--output json > instances.json
# Categorize by purpose/environment
cat instances.json | jq -r '
.[] |
{
id: .InstanceId,
type: .InstanceType,
vpc: .VpcId,
subnet: .SubnetId,
ami: .ImageId,
name: (.Tags // [] | map(select(.Key == "Name")) | .[0].Value // "unnamed"),
env: (.Tags // [] | map(select(.Key == "Environment")) | .[0].Value // "unknown")
}
' > inventory.json
# Count by instance type
cat instances.json | jq -r '.[].InstanceType' | sort | uniq -c | sort -rnPhase 2: Generate Terraform Configuration
Option A: Use Terraformer (recommended for large imports)
# Install terraformer
brew install terraformer
# Generate Terraform code from existing AWS resources
terraformer import aws \
--resources=ec2_instance,security_group,vpc,subnet \
--regions=us-east-1 \
--profile=production
# Output structure:
# generated/
# └── aws/
# └── ec2_instance/
# ├── instance.tf
# └── provider.tfOption B: Manual approach with import blocks (Terraform 1.5+)
# imports.tf - New in Terraform 1.5+
import {
to = aws_instance.web_server_1
id = "i-0abc123def456789"
}
import {
to = aws_instance.prod_api
id = "i-0789abc012def345"
}
# Generate config from imports
# terraform plan -generate-config-out=generated.tfOption C: Script-based config generation
#!/bin/bash
# generate-tf-config.sh
aws ec2 describe-instances --output json | jq -r '
.Reservations[].Instances[] |
"resource \"aws_instance\" \"\(.Tags | map(select(.Key==\"Name\"))[0].Value // .InstanceId | gsub(\"-\"; \"_\"))\" {
# Instance ID: \(.InstanceId)
ami = \"\(.ImageId)\"
instance_type = \"\(.InstanceType)\"
subnet_id = \"\(.SubnetId)\"
vpc_security_group_ids = [\(.SecurityGroups | map(\"\\\"\(.GroupId)\\\"\") | join(\", \"))]
tags = {
Name = \"\(.Tags | map(select(.Key==\"Name\"))[0].Value // \"unnamed\")\"
}
}
"
' > generated_instances.tfPhase 3: Import in Batches
#!/bin/bash
# import-batch.sh - Import instances in manageable batches
BATCH_SIZE=10
INSTANCES=$(aws ec2 describe-instances --query 'Reservations[].Instances[].InstanceId' --output text)
count=0
for instance_id in $INSTANCES; do
# Get instance name for resource naming
name=$(aws ec2 describe-instances --instance-ids $instance_id \
--query 'Reservations[].Instances[].Tags[?Key==`Name`].Value' --output text)
# Sanitize name for Terraform resource name
tf_name=$(echo "${name:-$instance_id}" | tr '-' '_' | tr '[:upper:]' '[:lower:]')
echo "Importing $instance_id as aws_instance.$tf_name"
terraform import "aws_instance.$tf_name" "$instance_id"
((count++))
# Verify after each batch
if [ $((count % BATCH_SIZE)) -eq 0 ]; then
echo "=== Verifying batch (${count} instances imported) ==="
terraform plan -detailed-exitcode
if [ $? -eq 2 ]; then
echo "WARNING: Plan shows changes. Review before continuing."
read -p "Continue? (y/n) " -n 1 -r
echo
if [[ ! $REPLY =~ ^[Yy]$ ]]; then
exit 1
fi
fi
fi
donePhase 4: Verify Configuration Matches Reality
# Run plan - should show NO changes if config matches
terraform plan
# If plan shows changes, your config doesn't match actual state
# Common mismatches:
# 1. Root volume settings
resource "aws_instance" "web" {
# Add this block to match existing root volume
root_block_device {
volume_size = 100 # Match actual size
volume_type = "gp3"
delete_on_termination = true
encrypted = true
}
}
# 2. Network interfaces
resource "aws_instance" "web" {
# If instance has multiple ENIs, might need:
network_interface {
network_interface_id = "eni-0abc123"
device_index = 0
}
}
# 3. IAM instance profile
resource "aws_instance" "web" {
iam_instance_profile = "existing-profile-name"
}Phase 5: Handle Lifecycle Configurations
# Prevent accidental changes to critical attributes
resource "aws_instance" "production_db" {
# ... config ...
lifecycle {
# Prevent destruction without explicit approval
prevent_destroy = true
# Ignore changes made outside Terraform
ignore_changes = [
user_data, # Often changed for bootstrapping
tags["LastUpdated"], # Auto-updated tags
]
}
}Phase 6: Establish Naming Conventions
# locals.tf - Standardize naming going forward
locals {
name_prefix = "${var.environment}-${var.application}"
common_tags = {
Environment = var.environment
Application = var.application
ManagedBy = "terraform"
ImportedFrom = "manual"
ImportDate = "2024-01-15"
}
}
# Use for new resources and gradually update imported ones
resource "aws_instance" "web" {
# ... config ...
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-${count.index + 1}"
Role = "web-server"
})
}Handling Related Resources
# Don't forget dependent resources!
# Security Groups
import {
to = aws_security_group.web
id = "sg-0abc123def"
}
# EBS Volumes (if not root volumes)
import {
to = aws_ebs_volume.data
id = "vol-0abc123def"
}
# Elastic IPs
import {
to = aws_eip.web
id = "eipalloc-0abc123def"
}
# EIP Association
import {
to = aws_eip_association.web
id = "eipassoc-0abc123def"
} Import Checklist
| Step | Action | Verification |
|---|---|---|
| 1 | Inventory all resources | Count matches AWS console |
| 2 | Generate Terraform config | Syntax valid (terraform validate) |
| 3 | Import resources | State shows resources |
| 4 | Run terraform plan | No changes shown |
| 5 | Fix config mismatches | Re-run plan until clean |
| 6 | Test with -target | Individual resources stable |
| 7 | Document imported resources | README updated |
Common Import Gotchas
# 1. Security group rules - import the group, not individual rules
terraform import aws_security_group.web sg-0abc123
# Rules are imported with the group
# 2. Auto-scaling groups - instances are separate
terraform import aws_autoscaling_group.web my-asg
# Don't import the instances - ASG manages them
# 3. RDS with read replicas - import separately
terraform import aws_db_instance.primary my-db
terraform import aws_db_instance.replica my-db-replica
# 4. Resources with count/for_each
terraform import 'aws_instance.web[0]' i-0abc123
terraform import 'aws_instance.web["server-1"]' i-0def456
Practice Question
After importing 200 EC2 instances, terraform plan shows changes to all instances. What is the most likely cause?