Introduction
You've been managing infrastructure through the AWS console for months—maybe years—and now you want to move it all under Terraform. The challenge: those resources already exist, and you can't just tear them down and recreate them. That's where Terraform import existing resources: a step-by-step guide becomes your lifeline. In this article, we'll walk through the modern approach using Terraform 1.5+ import blocks, show you how to generate boilerplate configuration, and help you refactor everything into a maintainable state.
Why importing existing resources matters
Most teams don't start with infrastructure-as-code. You provision an S3 bucket, configure a security group, spin up a database—all through the console or CLI scripts. Then one day you realize this is unmaintainable, undocumented, and risky. Terraform gives you a path forward without downtime: you import those resources into your state file and gradually bring them under code control.
The real value isn't just documentation; it's predictability and auditability. When every change flows through version control and plan output, your team knows what's about to happen before it happens. Importing existing resources is the bridge between ad-hoc infrastructure and proper IaC governance.
Understanding import blocks—declarative imports in Terraform 1.5+
Before Terraform 1.5, importing required imperative commands: terraform import aws_s3_bucket.assets acme-assets. This worked, but it wasn't repeatable or version-controlled. With import blocks, you declare your intent directly in code, and Terraform handles the rest during planning.
An import block tells Terraform: "This resource exists in the real world under this ID, and I want to manage it as this resource in my state." Here's the syntax:
import {
to = aws_s3_bucket.assets
id = "acme-assets"
}Breaking this down:
- to: The resource address in your Terraform configuration (resource type + logical name).
- id: The real-world identifier. For S3, it's the bucket name. For EC2 instances, it's the instance ID. For RDS, it's the DB identifier.
You can have multiple import blocks in the same file or spread across your configuration. Terraform tracks which imports have been completed and only re-imports if the block changes or the resource isn't in state.
Real-world example: Your team manually created three S3 buckets for different environments: acme-assets, acme-logs, and acme-backups. Instead of three separate import commands, you write:
import {
to = aws_s3_bucket.assets
id = "acme-assets"
}
import {
to = aws_s3_bucket.logs
id = "acme-logs"
}
import {
to = aws_s3_bucket.backups
id = "acme-backups"
}Commit this to your repository, run terraform plan, and Terraform fetches the current configuration of each bucket from AWS.
Generate config—terraform plan -generate-config-out writes a starting point
Here's where the magic happens. When you have import blocks defined and run terraform plan -generate-config-out=generated.tf, Terraform doesn't just bring resources into state—it writes out the HCL configuration for each one. This saves you from manually researching every property.
Let's say you have this in your configuration:
import {
to = aws_s3_bucket.assets
id = "acme-assets"
}You haven't defined the resource yet. Running terraform plan -generate-config-out=generated.tf produces something like:
resource "aws_s3_bucket" "assets" {
bucket = "acme-assets"
object_lock_enabled = false
tags = {
Environment = "production"
Owner = "platform-team"
}
}
resource "aws_s3_bucket_versioning" "assets" {
bucket = aws_s3_bucket.assets.id
versioning_configuration {
status = "Enabled"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "assets" {
bucket = aws_s3_bucket.assets.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = "AES256"
}
}
}Terraform introspects the actual AWS resources and generates the HCL to match. This includes nested resources (versioning, encryption, CORS, etc.) that AWS separates into dedicated resource types.
Important caveat: The generated configuration is a starting point, not production-ready code. It often includes computed fields that you should remove, and it may not handle all edge cases. You'll refactor it next.
The workflow is:
- Write import blocks for all resources you want to bring in.
- Run
terraform plan -generate-config-out=generated.tf. - Review the generated code.
- Move and refactor it into your main configuration.
Clean up—refactor generated code into modules and variables
Generated code is verbose and repetitive. Before you commit it, refactor it into your team's standard structure: extract variables, create modules, and remove unnecessary computed fields.
For example, the generated S3 bucket code above might live in a module:
# modules/s3_bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.bucket_name
tags = var.tags
}
resource "aws_s3_bucket_versioning" "this" {
bucket = aws_s3_bucket.this.id
versioning_configuration {
status = var.versioning_enabled ? "Enabled" : "Suspended"
}
}
resource "aws_s3_bucket_server_side_encryption_configuration" "this" {
bucket = aws_s3_bucket.this.id
rule {
apply_server_side_encryption_by_default {
sse_algorithm = var.encryption_algorithm
}
}
}And in your main configuration:
# main.tf
module "s3_assets" {
source = "./modules/s3_bucket"
bucket_name = "acme-assets"
versioning_enabled = true
encryption_algorithm = "AES256"
tags = {
Environment = "production"
Owner = "platform-team"
}
}
module "s3_logs" {
source = "./modules/s3_bucket"
bucket_name = "acme-logs"
versioning_enabled = true
encryption_algorithm = "AES256"
tags = {
Environment = "production"
Owner = "platform-team"
}
}And update your import blocks to reference the module resources:
import {
to = module.s3_assets.aws_s3_bucket.this
id = "acme-assets"
}
import {
to = module.s3_logs.aws_s3_bucket.this
id = "acme-logs"
}This approach gives you:
- Reusable bucket templates instead of copy-paste code.
- Clear variable intent (versioning_enabled is obvious; computed fields like object_lock_enabled are removed).
- Easier testing and code review.
Plan should show no changes—verifying the import is complete
Once you've refactored and organized your configuration, run terraform plan again. The output should show no changes. If it does, you've got a mismatch between your code and the actual resource state.
Common reasons for unexpected changes:
- Computed fields: Terraform shows changes for fields you didn't explicitly set but AWS has defaults for. Remove these from your code.
- Drift: Someone modified the resource in the console after you imported it. Either update your code to match, or identify why that drift happened and prevent it going forward.
- Provider version mismatch: Different Terraform or AWS provider versions may interpret resource properties differently. Check your provider version constraints.
Here's a practical example. After importing an S3 bucket, you see this in your plan:
~ resource "aws_s3_bucket" "assets" {
~ acl = "private" -> (known after apply)
}This means your code is trying to set ACL to "private", but AWS has a different value stored. Add the actual value to your configuration:
resource "aws_s3_bucket" "assets" {
bucket = "acme-assets"
acl = "private"
tags = var.tags
}Run terraform plan again. No changes. Import complete.
This zero-change plan is your confirmation that Terraform now accurately represents the real infrastructure and won't accidentally modify it on the next apply.
Common mistakes
Mistake 1: Forgetting to remove import blocks after the resource is in state. Import blocks are one-time operations. After the resource is successfully imported, you can leave them in place (Terraform ignores them on subsequent runs) or remove them. Either way works, but remove them to keep configuration clean.
Mistake 2: Trying to import a resource that's already in state. If you accidentally run import twice, Terraform will error. Always check terraform state list first.
Mistake 3: Importing resources with wrong IDs. The ID must match exactly what AWS knows about the resource. For S3, it's the bucket name. For an EC2 instance, it's i-0abc123. Mismatched IDs cause the import to fail silently or import the wrong resource.
Mistake 4: Not cleaning up generated code. Generated HCL is a rough starting point. Leaving it as-is means your codebase carries unnecessary computed fields, inconsistent naming, and no reusability. Always refactor.
Mistake 5: Importing without version control. Import blocks should be committed to your repository before running the import. This way, your team knows what's being imported and why.
Key takeaways
- Import blocks (Terraform 1.5+) provide a declarative, version-controlled way to bring existing resources under IaC management.
- Use
terraform plan -generate-config-out=generated.tfto generate boilerplate configuration for imported resources. - Always refactor generated code: remove computed fields, extract variables, and organize into modules for reusability.
- A successful import shows zero changes in
terraform plan, proving your code accurately reflects the real infrastructure. - Importing is incremental—you don't have to migrate everything at once. Import critical resources first, then expand coverage over time.
- Keep import blocks in version control so your team has a record of what was migrated and when.






Comments
Loading comments…