---
title: "Terraform Import and Brownfield Adoption: Bringing Existing Infrastructure Under Code"
description: "How to bring manually created cloud infrastructure under Terraform management. Covers the legacy terraform import command, import blocks (Terraform 1.5+), planning an import campaign for large environments, handling attribute drift between real resources and generated code, state surgery patterns, and the agent workflow for systematic brownfield adoption."
url: https://agent-zone.ai/knowledge/infrastructure/terraform-import-brownfield/
section: knowledge
date: 2026-02-22
categories: ["infrastructure"]
tags: ["terraform","import","brownfield","migration","state","adoption","existing-infrastructure","terraform-1.5"]
skills: ["terraform-import","brownfield-adoption","state-management","infrastructure-migration"]
tools: ["terraform","aws-cli","az","gcloud"]
levels: ["intermediate","advanced"]
word_count: 1779
formats:
  json: https://agent-zone.ai/knowledge/infrastructure/terraform-import-brownfield/index.json
  html: https://agent-zone.ai/knowledge/infrastructure/terraform-import-brownfield/?format=html
  api: https://api.agent-zone.ai/api/v1/knowledge/search?q=Terraform+Import+and+Brownfield+Adoption%3A+Bringing+Existing+Infrastructure+Under+Code
---


# Terraform Import and Brownfield Adoption

Most organizations do not start with Infrastructure as Code. They start with console clicks, CLI commands, and scripts. At some point they decide to adopt Terraform — and now they have hundreds of existing resources that need to be brought under management without disruption.

This is the brownfield problem: writing Terraform code that matches existing infrastructure exactly, importing the state so Terraform knows about the resources, and resolving the inevitable drift between what exists and what the code describes.

## Two Import Methods

### Legacy: `terraform import` Command

The original method (all Terraform versions). You write the resource block first, then import:

```bash
# Step 1: Write the resource block in .tf file
# (You must know the resource type and all required attributes)

# Step 2: Import into state
terraform import aws_vpc.main vpc-0abc123def456

# Step 3: Run plan to see drift
terraform plan
# Shows differences between your code and the real resource
# Fix your code until the plan shows no changes
```

**Limitations**:
- One resource at a time (slow for large imports)
- Does not generate code — you write it manually
- If your code does not match the real resource, `terraform plan` shows changes
- No dry-run — the import modifies state immediately

### Modern: Import Blocks (Terraform 1.5+)

Import blocks declare imports in code. Combined with `terraform plan -generate-config-out`, Terraform can generate the resource code for you:

```hcl
# import.tf — declare what to import
import {
  to = aws_vpc.main
  id = "vpc-0abc123def456"
}

import {
  to = aws_subnet.public_a
  id = "subnet-0abc123def456"
}

import {
  to = aws_subnet.public_b
  id = "subnet-0def456abc789"
}

import {
  to = aws_security_group.web
  id = "sg-0abc123def456"
}
```

```bash
# Generate resource code from real infrastructure
terraform plan -generate-config-out=generated.tf

# Review generated code, clean up, move to proper files
# Then apply to import into state
terraform apply
```

**Advantages over legacy import**:
- Batch imports (declare many at once)
- Code generation (Terraform writes the resource blocks)
- Dry-run (plan shows what will be imported before apply)
- Reviewable (import blocks are code in Git, can be reviewed in PR)
- Idempotent (running apply again does nothing after import succeeds)

## Planning an Import Campaign

### Inventory Phase

Before writing any Terraform, inventory what exists:

```bash
# AWS — list all resources in a region
aws resourcegroupstaggingapi get-resources \
  --region us-east-1 \
  --output json > aws-inventory.json

# AWS — specific resource types
aws ec2 describe-vpcs --region us-east-1
aws ec2 describe-subnets --region us-east-1
aws rds describe-db-instances --region us-east-1
aws eks list-clusters --region us-east-1

# Azure — list all resources in a subscription
az resource list --output json > azure-inventory.json

# GCP — list all resources in a project
gcloud asset search-all-resources \
  --project=my-project \
  --format=json > gcp-inventory.json
```

### Grouping Strategy

Import resources in dependency order, grouped by concern:

```
Phase 1: Networking (no dependencies)
  ├── VPC / VNET / VPC Network
  ├── Subnets
  ├── Route tables
  ├── NAT gateways
  └── Security groups / NSGs / Firewall rules

Phase 2: Identity and Access (depends on Phase 1 for some)
  ├── IAM roles / Managed identities / Service accounts
  ├── IAM policies / Role assignments / IAM bindings
  └── KMS keys / Key Vault / Cloud KMS

Phase 3: Data (depends on Phases 1-2)
  ├── RDS / Azure SQL / Cloud SQL
  ├── S3 / Storage Account / GCS
  └── ElastiCache / Redis / Memorystore

Phase 4: Compute (depends on all above)
  ├── EKS / AKS / GKE
  ├── EC2 / VMs / Compute Engine
  └── Load balancers
```

Each phase becomes a separate Terraform root module with its own state file. This matches the state decomposition pattern from the agent-oriented Terraform approach.

### Import Block Patterns by Cloud

**AWS resource IDs**:

```hcl
# Most AWS resources use their ARN or resource-specific ID
import { to = aws_vpc.main;              id = "vpc-0abc123" }
import { to = aws_subnet.public;         id = "subnet-0abc123" }
import { to = aws_security_group.web;    id = "sg-0abc123" }
import { to = aws_iam_role.app;          id = "my-app-role" }      # name, not ARN
import { to = aws_s3_bucket.data;        id = "my-bucket-name" }   # bucket name
import { to = aws_db_instance.main;      id = "my-rds-instance" }  # DB identifier
import { to = aws_eks_cluster.main;      id = "my-cluster-name" }  # cluster name
```

**Azure resource IDs**:

```hcl
# Azure uses full resource IDs (long paths)
import {
  to = azurerm_resource_group.main
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg"
}

import {
  to = azurerm_virtual_network.main
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.Network/virtualNetworks/my-vnet"
}

import {
  to = azurerm_kubernetes_cluster.main
  id = "/subscriptions/00000000-0000-0000-0000-000000000000/resourceGroups/my-rg/providers/Microsoft.ContainerService/managedClusters/my-aks"
}
```

**GCP resource IDs**:

```hcl
# GCP uses project/region/name or project/name format
import {
  to = google_compute_network.main
  id = "projects/my-project/global/networks/my-vpc"
}

import {
  to = google_compute_subnetwork.main
  id = "projects/my-project/regions/us-central1/subnetworks/my-subnet"
}

import {
  to = google_container_cluster.main
  id = "projects/my-project/locations/us-central1/clusters/my-gke"
}

import {
  to = google_sql_database_instance.main
  id = "projects/my-project/instances/my-cloudsql"
}
```

**Gotcha**: Finding the correct import ID format is the most common stumbling point. Check the Terraform provider documentation for each resource type — the "Import" section at the bottom of each resource page shows the expected format.

## Handling Drift After Import

After importing, `terraform plan` almost always shows changes. This is drift — differences between your code and the real resource.

### Types of Drift

```
# Type 1: Missing attribute — your code doesn't specify something the resource has
  ~ tags = {
      + "CreatedBy" = "manual"    # tag exists on resource but not in code
    }

# Type 2: Different value — your code specifies a different value
  ~ instance_type = "t3.medium" -> "t3.large"   # code says medium, reality is large

# Type 3: Computed attribute — Terraform wants to set a default
  ~ enable_dns_hostnames = true -> false   # provider default differs from reality
```

### Resolution Strategy

For each drift item, decide:

| Drift Type | Action | When |
|---|---|---|
| Code matches desired state | Let Terraform apply the change | The real resource was manually changed and should be corrected |
| Reality is correct | Update code to match | The code was wrong — the real resource is what you want |
| Attribute is auto-managed | Add `ignore_changes` | Auto-scaling `desired_capacity`, last-modified timestamps |
| Attribute is irrelevant | Add to code to match | Tags, descriptions — match reality to get a clean plan |

```hcl
# Example: after importing an ASG, desired_capacity drifts constantly
resource "aws_autoscaling_group" "main" {
  # ... imported attributes ...

  lifecycle {
    ignore_changes = [desired_capacity]  # managed by auto-scaling, not Terraform
  }
}
```

### The Zero-Diff Goal

Keep iterating until `terraform plan` shows `No changes`. This is the definition of "successfully imported":

```bash
# The cycle:
terraform plan          # shows drift
# Fix code to match reality (or decide to let Terraform fix reality)
terraform plan          # fewer diffs
# Repeat until:
terraform plan
# No changes. Your infrastructure matches the configuration.
```

**Agent rule**: After import, never apply until the plan shows exactly the changes you intend. A plan that shows 50 unexpected changes after import means the code is wrong — fix the code, do not apply.

## Generated Code Cleanup

`terraform plan -generate-config-out=generated.tf` produces valid but ugly code. It includes every attribute, even computed ones and defaults:

```hcl
# Generated — verbose, includes computed attributes
resource "aws_vpc" "main" {
  arn                                  = "arn:aws:ec2:us-east-1:123456789:vpc/vpc-0abc123"
  cidr_block                           = "10.0.0.0/16"
  default_network_acl_id               = "acl-0abc123"
  default_route_table_id               = "rtb-0abc123"
  default_security_group_id            = "sg-0abc123"
  dhcp_options_id                      = "dopt-0abc123"
  enable_dns_hostnames                 = true
  enable_dns_support                   = true
  enable_network_address_usage_metrics = false
  id                                   = "vpc-0abc123"
  instance_tenancy                     = "default"
  ipv6_association_id                  = null
  ipv6_cidr_block                      = null
  ipv6_cidr_block_network_border_group = null
  ipv6_ipam_pool_id                    = null
  ipv6_netmask_length                  = 0
  main_route_table_id                  = "rtb-0abc123"
  owner_id                             = "123456789"
  tags                                 = { "Name" = "production-vpc" }
  tags_all                             = { "Name" = "production-vpc" }
}
```

Clean it up:

```hcl
# Cleaned — only attributes you set intentionally
resource "aws_vpc" "main" {
  cidr_block           = "10.0.0.0/16"
  enable_dns_hostnames = true
  enable_dns_support   = true

  tags = { Name = "production-vpc" }
}
```

**What to remove from generated code**:
- Computed attributes (ARN, ID, owner_id) — Terraform manages these
- Attributes set to their default value (instance_tenancy = "default")
- `tags_all` (computed from `tags` + provider default tags)
- Null values
- Attributes you do not want Terraform to manage

**What to keep**:
- Attributes you explicitly set (CIDR, name, size, configuration)
- Attributes that differ from defaults
- Tags (the `tags` block, not `tags_all`)

## Large-Scale Import Workflow

For environments with hundreds of resources:

### Step 1: Generate Import Blocks Programmatically

```bash
# AWS — generate import blocks for all VPCs
aws ec2 describe-vpcs --query 'Vpcs[].VpcId' --output text \
  | tr '\t' '\n' \
  | awk '{printf "import {\n  to = aws_vpc.vpc_%s\n  id = \"%s\"\n}\n\n", NR, $1}'

# Output:
# import {
#   to = aws_vpc.vpc_1
#   id = "vpc-0abc123def456"
# }
```

### Step 2: Generate and Clean Code

```bash
# Generate config for all imports
terraform plan -generate-config-out=generated.tf

# Review, clean, reorganize into proper files
# Move networking resources to networking.tf
# Move compute resources to compute.tf
# etc.
```

### Step 3: Iterative Import

```bash
# Import in phases, validating each phase
terraform apply -target=aws_vpc.main
terraform plan  # verify VPC is clean

terraform apply -target=aws_subnet.public_a -target=aws_subnet.public_b
terraform plan  # verify subnets are clean

# Continue until all resources are imported
terraform apply  # final apply for remaining resources
terraform plan   # must show "No changes"
```

### Step 4: Remove Import Blocks

After successful import, the import blocks are no longer needed. They are idempotent (re-applying does nothing), but removing them keeps the code clean:

```bash
# After confirming successful import
rm import.tf
terraform plan  # should still show "No changes"
```

## Common Import Gotchas

| Gotcha | Symptom | Fix |
|---|---|---|
| Wrong import ID format | `Error: Cannot import` with unhelpful message | Check provider docs "Import" section for correct format |
| Resource already in state | `Resource already managed by Terraform` | Use `terraform state rm` first if re-importing |
| Missing required attribute | Plan shows forced replacement after import | Add the missing attribute to match reality |
| Provider version mismatch | Import works but plan shows unexpected changes | Pin provider version, check changelog for attribute renames |
| Sensitive attributes | Generated code shows `(sensitive value)` placeholders | Manually set sensitive attributes (passwords, keys) |
| `for_each` vs individual resources | Want to import into `aws_subnet.main["public-a"]` | Import ID includes the key: `terraform import 'aws_subnet.main["public-a"]' subnet-0abc` |
| Module resources | Want to import into `module.vpc.aws_vpc.main` | Full address: `terraform import 'module.vpc.aws_vpc.main' vpc-0abc` |
| Cross-account resources | Import fails with access denied | Configure the correct provider alias before importing |

## Agent Workflow for Brownfield Adoption

1. **Inventory**: List all resources in the target environment using cloud CLI
2. **Group**: Organize resources by dependency layer (networking → identity → data → compute)
3. **Write import blocks**: Create `import.tf` with import blocks for the first layer
4. **Generate code**: Run `terraform plan -generate-config-out=generated.tf`
5. **Clean code**: Remove computed attributes, organize into proper files
6. **Validate**: Run `terraform plan` — resolve all drift until zero-diff
7. **Import**: Run `terraform apply` to perform the imports
8. **Verify**: Run `terraform plan` — must show "No changes"
9. **Repeat**: Move to the next dependency layer
10. **Clean up**: Remove import blocks after all layers are imported
11. **Document**: Record what was imported, what was left unmanaged, and why

**Key principle**: Import is a read operation — it does not change any real infrastructure. The danger comes from the first `terraform apply` after import if your code does not match reality. Always achieve zero-diff before allowing any applies on imported state.

