Terraform Bulk Import: Bring Existing AWS Resources Under Management
Import dozens of existing AWS resources into Terraform at once using import blocks, for_each, and generate-config-out.
DevOps
Terraform module best practices for 2026. Learn module structure, versioning, composition, testing, when to create modules, and when not to over-abstract.
Modules are Terraform's unit of reuse — a directory of .tf files you can call from multiple places. Good modules save hours. Bad modules create more problems than they solve. Here's how to build modules that work.
modules/
└── vpc/
├── main.tf # Resources
├── variables.tf # Input variables
├── outputs.tf # Output values
├── versions.tf # Provider and Terraform version constraints
├── README.md # Documentation
└── examples/
└── basic/
└── main.tf # Usage exampleterraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = ">= 5.0"
}
}
}Don't pin exact provider versions in modules — let the caller decide:
# ❌ Too restrictive — breaks when caller uses 5.50
version = "= 5.40.0"
# ✅ Minimum version with flexibility
version = ">= 5.0"Create a module when:
Don't create a module when:
# ❌ Pointless wrapper — just use the resource directly
module "bucket" {
source = "./modules/s3-bucket"
name = "my-bucket"
}
# modules/s3-bucket/main.tf
resource "aws_s3_bucket" "this" {
bucket = var.name
}
# ✅ Useful module — bundles encryption, versioning, lifecycle, and policy
module "bucket" {
source = "./modules/secure-bucket"
name = "my-bucket"
retention_days = 90
}# modules/ecs-service/variables.tf
variable "name" {
type = string
description = "Service name"
}
# Opinionated default — most services need this
variable "health_check_grace_period" {
type = number
default = 60
}
# Opinionated default — production-ready
variable "enable_container_insights" {
type = bool
default = true
}
# Flexible — caller decides
variable "desired_count" {
type = number
description = "Number of tasks"
# No default — caller must think about this
}# ❌ Module creates its own provider — breaks multi-region
# modules/vpc/main.tf
provider "aws" {
region = var.region
}
# ✅ Module inherits provider from caller
# The caller passes the provider
module "vpc_us" {
source = "./modules/vpc"
providers = { aws = aws.us_east_1 }
}
module "vpc_eu" {
source = "./modules/vpc"
providers = { aws = aws.eu_west_1 }
}# modules/vpc/outputs.tf
# ✅ Expose commonly needed attributes
output "vpc_id" {
value = aws_vpc.this.id
}
output "private_subnet_ids" {
value = aws_subnet.private[*].id
}
output "public_subnet_ids" {
value = aws_subnet.public[*].id
}
output "vpc_cidr_block" {
value = aws_vpc.this.cidr_block
}
# ✅ Expose the full resource when needed
output "vpc" {
value = aws_vpc.this
description = "Full VPC resource (for advanced use)"
}# Resource names inside modules: use "this" for the primary resource
resource "aws_vpc" "this" { ... }
resource "aws_s3_bucket" "this" { ... }
# If multiple resources of the same type: use descriptive names
resource "aws_subnet" "public" { ... }
resource "aws_subnet" "private" { ... }Compose small modules into larger ones:
# modules/platform/main.tf — composes smaller modules
module "vpc" {
source = "../vpc"
vpc_cidr = var.vpc_cidr
}
module "database" {
source = "../rds"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}
module "cluster" {
source = "../ecs"
vpc_id = module.vpc.vpc_id
subnet_ids = module.vpc.private_subnet_ids
}Root config
└── module "platform"
├── module "vpc"
├── module "database"
└── module "cluster"Keep nesting shallow — max 2-3 levels. Deep nesting makes debugging hard.
module "vpc" {
source = "git::https://github.com/company/terraform-modules.git//modules/vpc?ref=v1.2.0"
}module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0" # Compatible 5.x releases
}version = "5.0.0" # Exact
version = "~> 5.0" # >= 5.0.0, < 6.0.0
version = ">= 5.0" # 5.0 or higher
version = ">= 5.0, < 6.0" # Explicit rangeUse ~> for modules — allows patch/minor updates, blocks breaking changes.
# tests/vpc.tftest.hcl
run "vpc_basic" {
command = plan
variables {
vpc_cidr = "10.0.0.0/16"
environment = "test"
}
assert {
condition = aws_vpc.this.cidr_block == "10.0.0.0/16"
error_message = "VPC CIDR mismatch"
}
assert {
condition = length(aws_subnet.private) == 3
error_message = "Expected 3 private subnets"
}
}Every module should have an examples/ directory:
# modules/vpc/examples/basic/main.tf
module "vpc" {
source = "../../"
vpc_cidr = "10.0.0.0/16"
environment = "example"
azs = ["us-east-1a", "us-east-1b"]
}
output "vpc_id" {
value = module.vpc.vpc_id
}# ❌ One module that does everything
module "infrastructure" {
source = "./modules/everything"
# 50 variables for VPC + ECS + RDS + CloudFront + Route53 + ...
}
# ✅ Compose focused modules
module "networking" { source = "./modules/vpc" }
module "database" { source = "./modules/rds" }
module "compute" { source = "./modules/ecs" }# ❌ Adds nothing — just use the resource
module "sg" {
source = "./modules/security-group"
name = "web"
vpc_id = module.vpc.vpc_id
ingress_port = 443
}# ❌ Caller must know internal resource structure
output "web_instance_private_dns" {
value = aws_instance.web_servers["primary"].private_dns
}
# ✅ Clean interface
output "web_endpoint" {
value = aws_lb.main.dns_name
}Good modules are focused (one concern), opinionated (sensible defaults), flexible (overridable), and tested (examples + tests). Don't wrap single resources. Don't create god modules. Compose small modules into larger ones with max 2-3 levels of nesting. Version with semantic tags and use ~> constraints. The best test for a module: can a new team member use it from the README alone?
Import dozens of existing AWS resources into Terraform at once using import blocks, for_each, and generate-config-out.
Learn Terraform data sources to read existing AWS resources, look up AMIs, query remote state, and reference external information in your configurations.
Learn when to use Terraform depends_on for explicit resource dependencies. Understand implicit vs explicit dependencies, common use cases
Terraform for_each vs count explained with practical examples. Learn when to use each, how to migrate from count to for_each