
Stop Repeating Yourself: The Terraform Mapping Construct Pattern

If you've ever copy-pasted a Terraform resource block and changed just a few values, you've felt the pain. Managing dozens of similar AWS resources—Lambda functions, S3 buckets, security groups—quickly becomes a maintenance nightmare.
What actually works is a simple, repeatable pattern you can drop into any Terraform project. The Terraform Mapping Construct Pattern reduces infrastructure code by up to 90% while enforcing consistency and making refactoring trivial. Here's your five minute write up to hopefully unlock some efficiencies in your workflow.
The Problem: The Copy-Paste Nightmare
Here's what managing multiple Lambda functions typically looks like:
resource "aws_lambda_function" "data_processor" {
function_name = "app-data-processor"
runtime = "python3.12"
memory_size = 512
timeout = 60
handler = "lambda_function.lambda_handler"
role = aws_iam_role.lambda_role.arn
vpc_config {
subnet_ids = var.subnet_ids
security_group_ids = [aws_security_group.lambda_sg.id]
}
environment {
variables = {
LOG_LEVEL = "INFO"
}
}
tags = {
Environment = "production"
}
}
resource "aws_lambda_function" "api_handler" {
function_name = "app-api-handler"
runtime = "python3.12"
memory_size = 512
timeout = 60
handler = "lambda_function.lambda_handler"
role = aws_iam_role.lambda_role.arn
vpc_config {
subnet_ids = var.subnet_ids
security_group_ids = [aws_security_group.lambda_sg.id]
}
environment {
variables = {
LOG_LEVEL = "INFO"
}
}
tags = {
Environment = "production"
}
}
# ... repeat for 10 more functions
Notice the duplication? Most of these properties are identical. Now imagine:
- Changing the runtime version across all functions
- Updating VPC configuration
- Adding a new tag to every resource
- Scaling to 50+ Lambda functions
This is where the mapping construct pattern shines.
The Solution: Three-Layer Architecture
The pattern separates concerns into three files. This isn't just documentation—it's a practical approach that works in production environments.
- variables.tf - Define defaults once
- locals.tf - Specify only what's unique
- main.tf - Deploy everything with
for_each
Let's rebuild those Lambda functions using this pattern.
Layer 1: Define Defaults (variables.tf)
First, establish your organizational standards:
variable "app_name" {
description = "Application name prefix"
type = string
default = "my-app"
}
variable "default_lambda_config" {
description = "Default Lambda function configuration"
type = any
default = {
runtime = "python3.12"
memory_size = 512
timeout = 60
handler = "lambda_function.lambda_handler"
log_retention_in_days = 7
tags = {
Environment = "production"
ManagedBy = "Terraform"
}
}
}
These defaults apply to every Lambda function unless explicitly overridden. Change the runtime here, and all functions inherit it.
Layer 2: Specify Uniqueness (locals.tf)
Now define only what makes each function different:
locals {
# Custom configurations - specify ONLY unique properties
custom_lambda_config = {
"data-processor" = {
description = "Processes incoming data files"
timeout = 300 # Override default 60s
environment_variables = {
BUCKET_NAME = "my-app-data"
}
}
"api-handler" = {
description = "Handles API requests"
memory_size = 1024 # Override default 512MB
environment_variables = {
API_KEY = "secret-key"
}
}
"report-generator" = {
description = "Generates daily reports"
timeout = 600
memory_size = 2048
}
}
# MERGE: Combine defaults + custom + computed values
lambda_config = {
for name, config in local.custom_lambda_config : name => merge(
var.default_lambda_config, # Start with defaults
config, # Override with custom values
{
# Add computed properties
function_name = "${var.app_name}-${name}"
source_path = "${path.module}/src/lambda/${name}"
}
)
}
}
The magic happens in the merge() function. It combines three layers:
- Start with defaults from
var.default_lambda_config - Override with resource-specific values from
config - Add computed properties like
function_name
The result is each function in lambda_config is fully configured, but you only wrote the unique parts. This isn't just theory—it's what actually works in production environments.
Layer 3: Deploy Everything (main.tf)
Finally, deploy all functions with a single module block:
module "lambda_function" {
source = "terraform-aws-modules/lambda/aws"
version = "7.14.0"
for_each = local.lambda_config
function_name = each.value.function_name
description = each.value.description
handler = each.value.handler
runtime = each.value.runtime
memory_size = each.value.memory_size
timeout = each.value.timeout
source_path = each.value.source_path
environment_variables = each.value.environment_variables
tags = each.value.tags
}
That's it. One module block deploys all three functions (or 30 functions—it doesn't matter).
The Power of for_each
The for_each meta-argument iterates over your configuration map. Each key becomes a resource instance. Want to add a fourth function? Just add it to custom_lambda_config:
locals {
custom_lambda_config = {
"data-processor" = { ... }
"api-handler" = { ... }
"report-generator" = { ... }
"email-sender" = { # New function!
description = "Sends notification emails"
timeout = 120
}
}
}
No changes to main.tf needed. The pattern scales linearly.
Advanced: Conditional Deployment
Sometimes you need different deployment strategies. Use inline filters with for_each:
# Deploy standard Lambda functions
module "lambda_function" {
source = "terraform-aws-modules/lambda/aws"
for_each = {
for name, config in local.lambda_config : name => config
if try(config.docker_deploy, false) == false
}
function_name = each.value.function_name
handler = each.value.handler
source_path = each.value.source_path
}
# Deploy Docker-based Lambda functions
module "lambda_function_docker" {
source = "terraform-aws-modules/lambda/aws"
for_each = {
for name, config in local.lambda_config : name => config
if try(config.docker_deploy, false) == true
}
function_name = each.value.function_name
package_type = "Image"
image_uri = each.value.image_uri
create_package = false
}
Same configuration map, different deployment methods. The filter determines which functions go where.
Cross-Resource Relationships
The pattern handles resource dependencies elegantly. Consider Lambda functions that need security groups:
# In main.tf - Security groups use the same map
resource "aws_security_group" "lambda_sg" {
for_each = local.lambda_config # Same map!
name = "${each.value.function_name}-sg"
description = "Security group for ${each.value.function_name}"
vpc_id = var.vpc_id
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
}
# Lambda functions reference their security groups
module "lambda_function" {
source = "terraform-aws-modules/lambda/aws"
for_each = local.lambda_config
function_name = each.value.function_name
vpc_security_group_ids = [aws_security_group.lambda_sg[each.key].id]
# ... other properties
}
Add a function to the map, get its security group automatically. Perfect synchronization.
Real-World Example: S3 Buckets
The pattern works for any AWS resource. Here's S3 buckets:
# variables.tf
variable "default_s3_config" {
default = {
versioning_enabled = true
force_destroy = false
tags = {
Environment = "production"
}
}
}
# locals.tf
locals {
custom_s3_config = {
"data-lake" = {
description = "Main data lake bucket"
}
"temp-files" = {
description = "Temporary processing files"
force_destroy = true # Override default
lifecycle_rules = [{
enabled = true
expiration_days = 7
}]
}
"backups" = {
description = "Application backups"
versioning_enabled = true
}
}
s3_config = {
for name, config in local.custom_s3_config : name => merge(
var.default_s3_config,
config,
{ bucket_name = "${var.app_name}-${name}" }
)
}
}
# main.tf
module "s3_bucket" {
source = "terraform-aws-modules/s3-bucket/aws"
version = "4.2.1"
for_each = local.s3_config
bucket = each.value.bucket_name
force_destroy = each.value.force_destroy
versioning = {
enabled = each.value.versioning_enabled
}
tags = each.value.tags
}
Three buckets, minimal code, perfect consistency.
Handling Resource References
When resources reference each other, timing matters:
For Resources Not Yet Created
Use string interpolation with predictable naming:
locals {
custom_lambda_config = {
"data-processor" = {
environment_variables = {
# Bucket doesn't exist yet, but we know its name
BUCKET_NAME = "${var.app_name}-data-lake"
}
policy_statements = {
s3_access = {
effect = "Allow"
actions = ["s3:GetObject", "s3:PutObject"]
# Construct ARN from known naming pattern
resources = [
"arn:aws:s3:::${var.app_name}-data-lake/*"
]
}
}
}
}
}
For Already-Created Resources
Reference module outputs directly:
locals {
custom_lambda_config = {
"data-processor" = {
environment_variables = {
# Reference actual created resource
TABLE_ARN = aws_dynamodb_table.main.arn
}
}
}
}
Terraform's dependency graph handles the rest.
Pattern Benefits
- DRY Principle: Define defaults once, inherit everywhere
- Consistency: Impossible to have configuration drift
- Easy Refactoring: Change defaults in one place
- Scalability: Linear growth (O(n) resources = O(n) config lines)
- Map Reusability: One map drives multiple resource types
- Maintainability: Clear separation of concerns
When to Use This Pattern
Perfect for:
- Multiple Lambda functions
- Multiple S3 buckets
- Multiple security groups
- Multiple IAM roles
- Any resource type with 3+ instances
Not needed for:
- Single-instance resources
- Resources with completely unique configurations
- Simple proof-of-concept projects
Getting Started
- Identify repetition in your Terraform code
- Extract common properties into
default_*_configvariables - Create custom config maps with only unique properties
- Merge configurations using
forloops andmerge() - Deploy with
for_eachin your module/resource blocks
Complete Working Example
Terraform Mapping Construct Example
locals.tf
locals {
# ============================================================================
# LAMBDA FUNCTIONS - Custom Configurations
# ============================================================================
# Specify ONLY properties that differ from defaults in variables.tf
custom_lambda_config = {
"data-processor" = {
description = "Processes incoming data files from S3"
timeout = 300 # Override default 60s for longer processing
memory_size = 1024 # Override default 512MB for data processing
environment_variables = {
SOURCE_BUCKET = "${var.app_name}-uploads"
DEST_BUCKET = "${var.app_name}-processed"
LOG_LEVEL = "INFO"
}
# IAM policy for S3 access
attach_policy_statements = true
policy_statements = {
s3_read = { sid = "S3Read", effect = "Allow", actions = ["s3:GetObject", "s3:ListBucket"], resources = ["arn:aws:s3:::${var.app_name}-uploads", "arn:aws:s3:::${var.app_name}-uploads/*"] }
s3_write = { sid = "S3Write", effect = "Allow", actions = ["s3:PutObject"], resources = ["arn:aws:s3:::${var.app_name}-processed/*"] }
}
}
"api-handler" = {
description = "Handles API requests and returns responses"
memory_size = 256 # Override - API handler needs less memory
environment_variables = {
API_VERSION = "v1"
LOG_LEVEL = "DEBUG"
}
# IAM policy for CloudWatch Logs only (inherited from defaults)
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
# Combines defaults + custom configs + computed properties
lambda_config = {
for name, config in local.custom_lambda_config : name => merge(
var.default_lambda_config, # Layer 1: Baseline defaults
config, # Layer 2: Resource-specific overrides
{
# Layer 3: Computed transformations
function_name = "${var.app_name}-${name}"
source_path = "${path.module}/src/lambda/functions/${name}"
layers = try(config.layers, []) # Add layers if specified
}
)
}
# ============================================================================
# S3 BUCKETS - Custom Configurations
# ============================================================================
custom_s3_config = {
"uploads" = {
description = "User file uploads bucket"
cors_rule = [{
allowed_headers = ["*"]
allowed_methods = ["PUT", "POST", "GET"]
allowed_origins = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3600
}]
}
"processed" = {
description = "Processed data storage"
lifecycle_rule = [{
id = "archive-old-data"
enabled = true
transition = [{
days = 90
storage_class = "GLACIER"
}]
}]
}
"temp-files" = {
description = "Temporary processing files"
force_destroy = true
lifecycle_rule = [{
id = "cleanup-temp-files"
enabled = true
expiration = {
days = 7
}
}]
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
s3_config = {
for name, config in local.custom_s3_config : name => merge(
var.default_s3_config,
config,
{
bucket = "${var.app_name}-${name}"
}
)
}
# ============================================================================
# LAMBDA LAYERS - Custom Configurations
# ============================================================================
custom_lambda_layer_config = {
"shared-utils" = {
description = "Shared utility functions for Lambda"
layer_folder_name = "shared-utils"
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
lambda_layer_config = {
for name, config in local.custom_lambda_layer_config : name => merge(
var.default_lambda_layer_config,
config,
{
layer_name = "${var.app_name}-${name}"
source_path = "${path.module}/src/lambda/layers/${config.layer_folder_name}"
}
)
}
}
Terraform Mapping Construct Example
locals.tf
locals {
# ============================================================================
# LAMBDA FUNCTIONS - Custom Configurations
# ============================================================================
# Specify ONLY properties that differ from defaults in variables.tf
custom_lambda_config = {
"data-processor" = {
description = "Processes incoming data files from S3"
timeout = 300 # Override default 60s for longer processing
memory_size = 1024 # Override default 512MB for data processing
environment_variables = {
SOURCE_BUCKET = "${var.app_name}-uploads"
DEST_BUCKET = "${var.app_name}-processed"
LOG_LEVEL = "INFO"
}
# IAM policy for S3 access
attach_policy_statements = true
policy_statements = {
s3_read = { sid = "S3Read", effect = "Allow", actions = ["s3:GetObject", "s3:ListBucket"], resources = ["arn:aws:s3:::${var.app_name}-uploads", "arn:aws:s3:::${var.app_name}-uploads/*"] }
s3_write = { sid = "S3Write", effect = "Allow", actions = ["s3:PutObject"], resources = ["arn:aws:s3:::${var.app_name}-processed/*"] }
}
}
"api-handler" = {
description = "Handles API requests and returns responses"
memory_size = 256 # Override - API handler needs less memory
environment_variables = {
API_VERSION = "v1"
LOG_LEVEL = "DEBUG"
}
# IAM policy for CloudWatch Logs only (inherited from defaults)
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
# Combines defaults + custom configs + computed properties
lambda_config = {
for name, config in local.custom_lambda_config : name => merge(
var.default_lambda_config, # Layer 1: Baseline defaults
config, # Layer 2: Resource-specific overrides
{
# Layer 3: Computed transformations
function_name = "${var.app_name}-${name}"
source_path = "${path.module}/src/lambda/functions/${name}"
layers = try(config.layers, []) # Add layers if specified
}
)
}
# ============================================================================
# S3 BUCKETS - Custom Configurations
# ============================================================================
custom_s3_config = {
"uploads" = {
description = "User file uploads bucket"
cors_rule = [{
allowed_headers = ["*"]
allowed_methods = ["PUT", "POST", "GET"]
allowed_origins = ["*"]
expose_headers = ["ETag"]
max_age_seconds = 3600
}]
}
"processed" = {
description = "Processed data storage"
lifecycle_rule = [{
id = "archive-old-data"
enabled = true
transition = [{
days = 90
storage_class = "GLACIER"
}]
}]
}
"temp-files" = {
description = "Temporary processing files"
force_destroy = true
lifecycle_rule = [{
id = "cleanup-temp-files"
enabled = true
expiration = {
days = 7
}
}]
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
s3_config = {
for name, config in local.custom_s3_config : name => merge(
var.default_s3_config,
config,
{
bucket = "${var.app_name}-${name}"
}
)
}
# ============================================================================
# LAMBDA LAYERS - Custom Configurations
# ============================================================================
custom_lambda_layer_config = {
"shared-utils" = {
description = "Shared utility functions for Lambda"
layer_folder_name = "shared-utils"
}
}
# ============================================================================
# MERGE OPERATION - DO NOT DELETE
# ============================================================================
lambda_layer_config = {
for name, config in local.custom_lambda_layer_config : name => merge(
var.default_lambda_layer_config,
config,
{
layer_name = "${var.app_name}-${name}"
source_path = "${path.module}/src/lambda/layers/${config.layer_folder_name}"
}
)
}
}
Conclusion
The Terraform Mapping Construct Pattern transforms infrastructure-as-code from repetitive copy-paste work into elegant, maintainable configuration. By separating defaults, customization, and deployment, you'll write less code, enforce consistency, and make refactoring trivial.
Start small: pick one resource type with multiple instances and apply the pattern. You'll immediately see the benefits. Then expand to your entire infrastructure.
Your future self (and your team) will thank you.
Bonus: AI-Assisted Terraform
Want AI assistants like GitHub Copilot or Kiro to automatically generate mapping constructs? Add this steering file to your project:
Create .kiro/steering/terraform-format.md:
<!-- ---
inclusion: fileMatch
fileMatchPattern: ['**/*.tf', '**/*.hcl']
---
-->
# Terraform Standards
## Multi-Resource Pattern
**CRITICAL**: For multiple instances of the same resource type, always use the mapping construct pattern:
### 1. Define defaults in variables.tf
variable "default_lambda_config" {
default = {
runtime = "python3.12"
memory_size = 512
timeout = 60
}
}
### 2. Merge configurations in locals.tf
locals {
custom_lambda_config = {
"function-name" = {
memory_size = 1024 # Override
}
}
lambda_config = {
for name, config in local.custom_lambda_config : name => merge(
var.default_lambda_config,
config,
{ function_name = "${var.app_name}-${name}" }
)
}
}
### 3. Deploy with for_each in main.tf
module "lambda_function" {
source = "terraform-aws-modules/lambda/aws"
for_each = local.lambda_config
function_name = each.value.function_name
runtime = each.value.runtime
memory_size = each.value.memory_size
}
Now AI tools will automatically follow this pattern when generating Terraform code for your project.