Cloud Engineering

Import Existing AWS Infrastructure into Terraform with import Blocks

Intermediate60 min to complete12 min readJuly 2, 2026Updated August 19, 2026

Quick answer

Stop bringing clickops resources under management with the fragile `terraform import` CLI. Use declarative `import` blocks, generate config with `-generate-config-out`, clean it up, and drive `terraform plan` to a clean no-op — the modern, reviewable workflow, step by step.

intermediate · 60 min

Before you begin

  • Terraform >= 1.5 or OpenTofu >= 1.6 installed
  • AWS CLI configured with read/write access to your account
  • At least one AWS resource created outside Terraform (e.g. a manually-created S3 bucket and a security group)
  • A fresh, empty Terraform working directory
Terraform
OpenTofu
AWS
Infrastructure as Code
import
State Management
DevOps

Every real AWS account has resources that were born in the console: a bucket someone made in a hurry, a security group from a proof of concept, an IAM role attached to a Lambda a year ago. Bringing that clickops estate under Terraform is unavoidable, and for years the only tool was the imperative terraform import CLI — one resource at a time, no plan, no review, and you still had to hand-write the matching HCL from memory.

Terraform 1.5 (and OpenTofu 1.6) fixed this with declarative import blocks plus config generation. You describe what you want to import in code, run a plan that both binds the resource and scaffolds its HCL, and review the whole thing as a diff before anything touches state. This tutorial walks the full loop on real AWS resources: an S3 bucket, a security group, and an IAM role. If you want the conceptual background and the trade-offs versus the old CLI, read the companion post on importing existing infrastructure into Terraform first.

We'll go past the happy path too — importing into a child module, importing a for_each instance (where config generation deliberately does not help), and using the removed block to forget a resource from state without destroying it.

What You'll Build

  • A Terraform config that adopts three pre-existing AWS resources — an S3 bucket, a security group, and an IAM role — with zero downtime
  • Declarative import blocks driving a reviewable plan instead of blind CLI imports
  • Generated HCL from -generate-config-out, then a cleaned, reference-based version
  • A clean terraform plan no-op — the real definition of "done"
  • Imports into a module address and a for_each key
  • A safe removed block to unmanage a resource without deleting it

Step 1: Identify the Real Resource IDs

The import ID is provider-specific, and getting it wrong is the most common failure. For AWS the three we care about here are:

  • aws_s3_bucket — the bucket name (my-company-data)
  • aws_security_group — the security group ID (sg-0abc123def456)
  • aws_iam_role — the role name (app-runtime-role)

Confirm each one with the AWS CLI so you're importing what you think you are:

bash
aws s3api list-buckets --query 'Buckets[].Name' --output table
aws ec2 describe-security-groups --query 'SecurityGroups[].[GroupId,GroupName]' --output table
aws iam list-roles --query 'Roles[].RoleName' --output table

When you're unsure of the exact ID format for any resource, the import ID is documented in the "Import" section at the bottom of that resource's provider docs page. Never guess.

Step 2: Set Up a Fresh Working Directory

Create an empty directory with just a provider block. Everything else will be generated or hand-written.

bash
mkdir tf-import && cd tf-import
hcl
1# providers.tf
2terraform {
3  required_version = ">= 1.5"
4  required_providers {
5    aws = {
6      source  = "hashicorp/aws"
7      version = "~> 5.0"
8    }
9  }
10}
11
12provider "aws" {
13  region = "us-east-1"
14}
bash
terraform init

If you're on OpenTofu, swap terraform for tofu in every command — the import block, -generate-config-out, moved, and removed all work identically from OpenTofu 1.6 onward.

Step 3: Write the import Blocks

An import block has two arguments: to (the resource address you want it to live at) and id (the provider's import ID from Step 1). Put these in their own file so they're easy to delete later.

hcl
1# imports.tf
2import {
3  to = aws_s3_bucket.data
4  id = "my-company-data"
5}
6
7import {
8  to = aws_security_group.app
9  id = "sg-0abc123def456"
10}
11
12import {
13  to = aws_iam_role.app_runtime
14  id = "app-runtime-role"
15}

These blocks are inert on their own — the target resources (aws_s3_bucket.data, etc.) don't exist in your config yet. That's fine; the next step generates them.

Step 4: Generate the Configuration

Run plan with -generate-config-out. Terraform reads each import block, fetches the live resource, and writes matching HCL to the file you name.

bash
terraform plan -generate-config-out=generated.tf

The plan output will report 3 to import and Terraform will create generated.tf containing a resource block for each imported object. If the plan instead errors with "Configuration for import target … does not exist," config generation did not run for that target — you either forgot the -generate-config-out flag, or the block targets something generation can't handle (a child module or an indexed instance). Add the flag, or hand-write that resource block, and re-run.

Important caveat: -generate-config-out only works for import blocks targeting whole resources in the root module — not for_each/count instances, and not resources inside child modules. If a block targets a module address or an indexed key, generation doesn't silently skip it — the plan fails with an error. So write those resource blocks by hand first (see Steps 7 and 8), then run the generate step only for the remaining plain root-module resources.

Step 5: Review and Clean the Generated Config

This is the step people skip and regret. Generated HCL is a faithful dump of the live resource, which means it is not production-ready:

  • It writes every attribute, including provider defaults and computed values you'd normally omit.
  • It uses hardcoded literals where you'd want references (e.g. the IAM role's ARN pasted into a policy instead of aws_iam_role.app_runtime.arn).
  • Sensitive values are left blank with a comment — you must fill them from a secure source, never commit secrets.
  • It may emit deprecated inline sub-arguments (e.g. aws_s3_bucket versioning as a nested block) that the provider now wants as separate resources.

Open generated.tf and trim it down to intent. A raw block might look like this:

hcl
1# generated.tf (raw — before cleanup)
2resource "aws_security_group" "app" {
3  name        = "app-sg"
4  description = "App tier"
5  vpc_id      = "vpc-0aa11bb22cc33dd44"
6  # ... 30+ lines of computed defaults, arn, owner_id, tags_all ...
7  ingress = [{
8    from_port   = 443
9    to_port     = 443
10    protocol    = "tcp"
11    cidr_blocks = ["0.0.0.0/0"]
12    # ... every optional field, all set to null ...
13  }]
14}

Cleaned up, keeping only what you actually manage:

hcl
1# main.tf (cleaned)
2resource "aws_security_group" "app" {
3  name        = "app-sg"
4  description = "App tier"
5  vpc_id      = "vpc-0aa11bb22cc33dd44"
6
7  ingress {
8    from_port   = 443
9    to_port     = 443
10    protocol    = "tcp"
11    cidr_blocks = ["0.0.0.0/0"]
12  }
13}

Delete generated.tf once you've moved the cleaned resources into main.tf. Don't leave the raw dump in your repo.

Step 6: Iterate plan to a Clean Import — Then Apply

Do not apply yet. A single terraform apply with import blocks does two things at once: it imports the resource and applies any diff between your (freshly hand-cleaned, probably slightly wrong) config and the live resource. If your main.tf dropped an ingress rule or mistyped an attribute, that apply will modify or destroy the real AWS resource at bind time. Iterating plan afterward is too late — the change is already done.

So gate on plan first:

bash
terraform plan

You want the summary to read N to import, 0 to add, 0 to change, 0 to destroy. Any to add, to change, or to destroy line means your HCL doesn't match reality yet. Read each proposed change — add a missing tag, fix an attribute, remove a field the provider computes — and re-plan. Repeat until the only planned action is the import (zero add/change/destroy).

Only once the plan shows import-and-nothing-else is it safe to run:

bash
terraform apply

This binds the resources into state without creating, changing, or destroying anything in AWS. That clean, change-free import is the definition of success: Terraform now describes the resource exactly, and future changes will be intentional.

Step 7: Import into a Module Address

Real configs use modules. You can import straight into a module's resource address — but remember config generation won't help here, so the module and its resource must already be defined.

hcl
# imports.tf
import {
  to = module.network.aws_security_group.app
  id = "sg-0abc123def456"
}

The module block and the aws_security_group.app resource inside modules/network/ must exist before you apply. Write that HCL yourself — or scaffold the module skeleton with Coding Protocols' Terraform Module Scaffolder — then terraform apply binds the live SG to module.network.aws_security_group.app.

Step 8: Import a for_each Instance

To adopt an existing resource into a for_each map, target the specific key. Again, generation is skipped — you write the resource block.

hcl
1# main.tf
2resource "aws_s3_bucket" "data" {
3  for_each = toset(["logs", "artifacts"])
4  bucket   = "my-company-${each.key}"
5}
6
7# imports.tf
8import {
9  to = aws_s3_bucket.data["logs"]
10  id = "my-company-logs"
11}
12
13import {
14  to = aws_s3_bucket.data["artifacts"]
15  id = "my-company-artifacts"
16}

Each import block maps one live bucket to one instance key. Apply, then drive plan to a no-op as before.

Step 9: Refactor Later with moved and removed

Once resources are managed, two more declarative blocks keep state changes reviewable:

moved — when you rename a resource or push it into a module, a moved block updates state addresses instead of destroy-and-recreate:

hcl
moved {
  from = aws_s3_bucket.data
  to   = module.storage.aws_s3_bucket.data
}

removed — to stop managing a resource without destroying it in AWS. The lifecycle block with destroy = false is required; without it, removed destroys the resource. Delete the resource block from your config and add:

hcl
1removed {
2  from = aws_s3_bucket.data
3
4  lifecycle {
5    destroy = false
6  }
7}

On the next apply, Terraform forgets the bucket from state and leaves it untouched in AWS. This is the safe, reviewable inverse of import.

Common Issues

  • Generated config needs manual cleanup — -generate-config-out dumps defaults, blanks secrets, and uses literals instead of references. Never apply it as-is; treat it as a starting draft (Step 5).
  • Config generation skipped for module / for_each targets — this is by design. -generate-config-out only handles whole resources in the root module. Hand-write HCL for module addresses and indexed keys (Steps 7-8).
  • "Resource already managed" / duplicate import — you left an import block in place after a successful apply. Delete imports.tf (or the individual block) once state is bound; leaving it is harmless but noisy.
  • Wrong ID format — the biggest source of couldn't find resource errors. IDs are provider-specific (bucket name vs sg- ID vs role name). Check the resource's "Import" docs section.
  • Provider doesn't support import — not every resource is importable. If the docs page has no "Import" section, you can't use an import block for it; recreate it in Terraform instead.
  • Persistent drift after a clean import — some attributes are computed or normalized by AWS (e.g. policy JSON whitespace, tags_all). Match the provider's canonical form or use jsonencode/ignore_changes for the noisy ones.

Frequently Asked Questions

Should I use import blocks or the old terraform import CLI?

Use import blocks for anything new. The CLI is imperative, produces no plan, imports one resource per invocation, and never generates config — you write all the HCL yourself and hope it matches. import blocks are declarative, reviewable in a pull request, support config generation, and can bulk-import in a single apply. The CLI still exists for edge cases and older Terraform versions, but the block is the modern default.

Does config generation work inside modules?

No. -generate-config-out only generates HCL for whole resources targeted at the root module. If an import block's to points at a module address or a for_each/count instance, Terraform doesn't silently skip it — the plan errors out. You must write those resource blocks by hand before running the generate step. This is the single most important limitation to remember.

Does importing a resource ever change or destroy it in AWS?

No. Import is a state-only operation — it records the live resource in Terraform's state so Terraform knows it exists. It never creates, modifies, or deletes the actual AWS resource. Any subsequent changes only happen if your HCL differs from reality, which is exactly why you iterate plan to a clean no-op before trusting the import.

How do I remove a resource from Terraform without deleting it in AWS?

Use a removed block with lifecycle { destroy = false } (required), delete the matching resource block, and apply. Terraform forgets the resource from state and leaves it running in AWS. The imperative equivalent is terraform state rm <address>, but the removed block is declarative and reviewable, so prefer it. If you're on OpenTofu, tofu state rm works the same way.

Can I import several resources at once, and does OpenTofu support all of this?

Yes to both. You can stack any number of import blocks and bind them in a single apply. And every feature here works identically in OpenTofu: import, -generate-config-out and moved from 1.6 onward, with declarative removed blocks arriving in OpenTofu 1.7 (matching Terraform 1.7). If you're weighing the two engines, see OpenTofu vs Terraform.

Tear Down

Because import is state-only, tearing down safely means unmanaging — not destroying — resources that existed before Terraform. Otherwise you'd delete infrastructure Terraform never created.

hcl
1# removed.tf — forget every imported resource, keep it in AWS
2removed {
3  from = aws_s3_bucket.data
4  lifecycle { destroy = false }
5}
6
7removed {
8  from = aws_security_group.app
9  lifecycle { destroy = false }
10}
11
12removed {
13  from = aws_iam_role.app_runtime
14  lifecycle { destroy = false }
15}
bash
terraform apply          # drops the resources from state, leaves AWS untouched

If instead you do want to delete everything Terraform now manages (only safe when nothing else depends on it):

bash
terraform destroy

For a scratch working directory, remove the local state and cache once you're done:

bash
rm -rf tf-import

Official References

We built Podscape to simplify Kubernetes workflows like this — logs, events, and cluster state in one interface, without switching tools.

Struggling with this in production?

We help teams fix these exact issues. Our engineers have deployed these patterns across production environments at scale.