Terraform & AWS DevOps · Lesson 3 of 6
Terraform Modules & Best Practices
Why Modules?
Imagine you deploy to three environments: dev, staging, and prod. Without modules you'd copy-paste the same Lambda + API Gateway + DynamoDB Terraform three times. When you need to change a security setting, you change it in three places and hope you don't miss one.
Modules solve this — define infrastructure once, instantiate it many times with different inputs.
modules/
└── serverless-api/ ← define once
├── main.tf
├── variables.tf
└── outputs.tf
environments/
├── dev/
│ └── main.tf ← call module with dev inputs
├── staging/
│ └── main.tf ← call module with staging inputs
└── prod/
└── main.tf ← call module with prod inputsWriting Your First Module
A Terraform module is just a directory of .tf files. No special declaration needed — any directory is a module.
Module: modules/serverless-api/
# modules/serverless-api/variables.tf
variable "environment" {
description = "Deployment environment (dev/staging/prod)"
type = string
}
variable "project_name" {
description = "Project name prefix"
type = string
}
variable "lambda_handler" {
description = "Lambda handler in format file.function_name"
type = string
default = "handler.lambda_handler"
}
variable "lambda_runtime" {
description = "Lambda runtime identifier"
type = string
default = "python3.12"
}
variable "lambda_memory_mb" {
type = number
default = 256
}
variable "lambda_timeout_seconds" {
type = number
default = 30
}
variable "lambda_source_dir" {
description = "Path to Lambda source code directory"
type = string
}
variable "environment_variables" {
description = "Environment variables to pass to Lambda"
type = map(string)
default = {}
}
variable "enable_point_in_time_recovery" {
description = "Enable DynamoDB PITR"
type = bool
default = false
}
variable "log_retention_days" {
type = number
default = 14
}
variable "tags" {
description = "Additional tags to merge"
type = map(string)
default = {}
}# modules/serverless-api/main.tf
locals {
name_prefix = "${var.project_name}-${var.environment}"
common_tags = merge(var.tags, {
Environment = var.environment
Project = var.project_name
ManagedBy = "terraform"
})
}
# DynamoDB
resource "aws_dynamodb_table" "main" {
name = "${local.name_prefix}-items"
billing_mode = "PAY_PER_REQUEST"
hash_key = "pk"
range_key = "sk"
attribute { name = "pk"; type = "S" }
attribute { name = "sk"; type = "S" }
point_in_time_recovery {
enabled = var.enable_point_in_time_recovery
}
server_side_encryption { enabled = true }
deletion_protection_enabled = var.environment == "prod"
tags = local.common_tags
}
# IAM Role
resource "aws_iam_role" "lambda" {
name = "${local.name_prefix}-lambda-role"
assume_role_policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = "sts:AssumeRole"
Principal = { Service = "lambda.amazonaws.com" }
}]
})
tags = local.common_tags
}
resource "aws_iam_role_policy_attachment" "basic_execution" {
role = aws_iam_role.lambda.name
policy_arn = "arn:aws:iam::aws:policy/service-role/AWSLambdaBasicExecutionRole"
}
resource "aws_iam_role_policy" "dynamodb" {
name = "dynamodb-access"
role = aws_iam_role.lambda.id
policy = jsonencode({
Version = "2012-10-17"
Statement = [{
Effect = "Allow"
Action = [
"dynamodb:GetItem", "dynamodb:PutItem", "dynamodb:UpdateItem",
"dynamodb:DeleteItem", "dynamodb:Query", "dynamodb:Scan"
]
Resource = [
aws_dynamodb_table.main.arn,
"${aws_dynamodb_table.main.arn}/index/*"
]
}]
})
}
# Lambda
data "archive_file" "lambda" {
type = "zip"
source_dir = var.lambda_source_dir
output_path = "${path.module}/.lambda_${var.environment}.zip"
}
resource "aws_cloudwatch_log_group" "lambda" {
name = "/aws/lambda/${local.name_prefix}-api"
retention_in_days = var.log_retention_days
tags = local.common_tags
}
resource "aws_lambda_function" "api" {
function_name = "${local.name_prefix}-api"
role = aws_iam_role.lambda.arn
runtime = var.lambda_runtime
handler = var.lambda_handler
filename = data.archive_file.lambda.output_path
source_code_hash = data.archive_file.lambda.output_base64sha256
memory_size = var.lambda_memory_mb
timeout = var.lambda_timeout_seconds
environment {
variables = merge(var.environment_variables, {
DYNAMODB_TABLE = aws_dynamodb_table.main.name
ENVIRONMENT = var.environment
})
}
depends_on = [aws_cloudwatch_log_group.lambda]
tags = local.common_tags
}
# API Gateway
resource "aws_apigatewayv2_api" "main" {
name = "${local.name_prefix}-api"
protocol_type = "HTTP"
tags = local.common_tags
}
resource "aws_apigatewayv2_integration" "lambda" {
api_id = aws_apigatewayv2_api.main.id
integration_type = "AWS_PROXY"
integration_uri = aws_lambda_function.api.invoke_arn
integration_method = "POST"
payload_format_version = "2.0"
}
resource "aws_apigatewayv2_route" "proxy" {
api_id = aws_apigatewayv2_api.main.id
route_key = "$default"
target = "integrations/${aws_apigatewayv2_integration.lambda.id}"
}
resource "aws_apigatewayv2_stage" "default" {
api_id = aws_apigatewayv2_api.main.id
name = "$default"
auto_deploy = true
}
resource "aws_lambda_permission" "api_gateway" {
statement_id = "AllowAPIGatewayInvoke"
action = "lambda:InvokeFunction"
function_name = aws_lambda_function.api.function_name
principal = "apigateway.amazonaws.com"
source_arn = "${aws_apigatewayv2_api.main.execution_arn}/*/*"
}# modules/serverless-api/outputs.tf
output "api_endpoint" {
description = "API Gateway invoke URL"
value = aws_apigatewayv2_stage.default.invoke_url
}
output "lambda_function_name" {
value = aws_lambda_function.api.function_name
}
output "lambda_function_arn" {
value = aws_lambda_function.api.arn
}
output "dynamodb_table_name" {
value = aws_dynamodb_table.main.name
}
output "dynamodb_table_arn" {
value = aws_dynamodb_table.main.arn
}Calling the Module
# environments/dev/main.tf
terraform {
required_providers {
aws = { source = "hashicorp/aws", version = "~> 5.0" }
}
backend "s3" {
bucket = "my-terraform-state"
key = "dev/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" { region = "us-east-1" }
module "api" {
source = "../../modules/serverless-api"
environment = "dev"
project_name = "learnixo"
lambda_source_dir = "${path.module}/../../lambda_src"
lambda_memory_mb = 256
lambda_timeout_seconds = 30
log_retention_days = 7
environment_variables = {
LOG_LEVEL = "DEBUG"
}
tags = {
CostCenter = "engineering"
}
}
output "dev_api_url" {
value = module.api.api_endpoint
}# environments/prod/main.tf
module "api" {
source = "../../modules/serverless-api"
environment = "prod"
project_name = "learnixo"
lambda_source_dir = "${path.module}/../../lambda_src"
lambda_memory_mb = 512
lambda_timeout_seconds = 60
enable_point_in_time_recovery = true
log_retention_days = 90
environment_variables = {
LOG_LEVEL = "WARNING"
}
}Prod gets more memory, longer retention, and PITR enabled — without duplicating all the infrastructure code.
Using the Public Registry
HashiCorp maintains a public Terraform registry with thousands of community and verified modules. Use battle-tested modules instead of writing everything from scratch.
# Use the official AWS VPC module
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
name = "my-vpc"
cidr = "10.0.0.0/16"
azs = ["us-east-1a", "us-east-1b", "us-east-1c"]
private_subnets = ["10.0.1.0/24", "10.0.2.0/24", "10.0.3.0/24"]
public_subnets = ["10.0.101.0/24", "10.0.102.0/24", "10.0.103.0/24"]
enable_nat_gateway = true
single_nat_gateway = true
tags = { Environment = "prod" }
}
# Use the official RDS module
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "~> 6.0"
identifier = "my-postgres"
engine = "postgres"
engine_version = "16"
instance_class = "db.t3.micro"
allocated_storage = 20
db_name = "appdb"
username = "admin"
port = 5432
vpc_security_group_ids = [module.vpc.default_security_group_id]
subnet_ids = module.vpc.private_subnets
create_db_subnet_group = true
family = "postgres16"
major_engine_version = "16"
deletion_protection = true
}Always pin module versions with
version = "~> x.y". Never use unpinned modules in production — they can introduce breaking changes when the registry updates.
Module Sources
Modules can be sourced from multiple locations:
# Local path
module "api" {
source = "./modules/serverless-api"
}
# Public registry
module "vpc" {
source = "terraform-aws-modules/vpc/aws"
version = "~> 5.0"
}
# GitHub (public repo)
module "custom" {
source = "github.com/my-org/terraform-modules//serverless-api?ref=v1.2.0"
}
# GitHub (private repo via SSH)
module "private" {
source = "git::ssh://git@github.com/my-org/private-modules.git//module?ref=v1.0.0"
}
# Terraform Cloud private registry
module "shared" {
source = "app.terraform.io/my-org/serverless-api/aws"
version = "~> 1.0"
}Best Practices
1. Pin Provider and Module Versions
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0" # Minor updates OK, major updates require explicit change
}
}
}Run terraform init -upgrade to update within constraints. Commit the generated .terraform.lock.hcl file — it pins exact provider versions like package-lock.json.
2. Use for_each Over count for Named Resources
# BAD: count creates aws_subnet.private[0], aws_subnet.private[1]
# Inserting in the middle renumbers everything
resource "aws_subnet" "private" {
count = 3
# ...
}
# GOOD: for_each creates aws_subnet.private["us-east-1a"], etc.
# Stable keys — adding/removing one doesn't affect others
resource "aws_subnet" "private" {
for_each = toset(["us-east-1a", "us-east-1b", "us-east-1c"])
availability_zone = each.value
cidr_block = cidrsubnet("10.0.0.0/16", 8, index(tolist(toset(["us-east-1a", "us-east-1b", "us-east-1c"])), each.value))
vpc_id = aws_vpc.main.id
}3. Separate State Per Environment
state/
├── dev/terraform.tfstate → s3://state-bucket/dev/terraform.tfstate
├── staging/terraform.tfstate → s3://state-bucket/staging/terraform.tfstate
└── prod/terraform.tfstate → s3://state-bucket/prod/terraform.tfstateNever share state between environments. A failed terraform apply on dev should never affect prod.
4. Use moved Blocks Instead of Destroying
When you rename a resource or refactor into a module, Terraform would destroy and recreate by default. Use moved blocks to preserve state:
# Refactoring: resource moved into a module
moved {
from = aws_lambda_function.api
to = module.api.aws_lambda_function.api
}5. lifecycle Rules for Production Safety
resource "aws_rds_cluster" "main" {
# ...
lifecycle {
# Prevent accidental deletion
prevent_destroy = true
# Ignore changes to password (managed outside Terraform)
ignore_changes = [master_password]
# Create new before destroying old (zero-downtime replacement)
create_before_destroy = true
}
}6. Consistent Tagging Strategy
locals {
required_tags = {
Project = var.project_name
Environment = var.environment
ManagedBy = "terraform"
Repository = "github.com/my-org/infra"
CostCenter = var.cost_center
}
}
# Use AWS provider default_tags to apply to all resources
provider "aws" {
region = var.aws_region
default_tags {
tags = local.required_tags
}
}7. Validate Inputs with validation Blocks
variable "environment" {
type = string
validation {
condition = contains(["dev", "staging", "prod"], var.environment)
error_message = "Environment must be dev, staging, or prod."
}
}
variable "lambda_memory_mb" {
type = number
validation {
condition = var.lambda_memory_mb >= 128 && var.lambda_memory_mb <= 10240
error_message = "Lambda memory must be between 128 MB and 10,240 MB."
}
}8. Document Modules with README
Every shared module should have a README.md generated from the code:
# Install terraform-docs
brew install terraform-docs
# Generate README from HCL comments and variable descriptions
terraform-docs markdown table . > README.mdDirectory Structure: Large Team Pattern
infra/
├── modules/
│ ├── serverless-api/ # Reusable Lambda + API GW + DynamoDB
│ ├── vpc/ # VPC with public/private subnets
│ ├── rds-postgres/ # Encrypted PostgreSQL with parameter groups
│ └── ecs-service/ # ECS Fargate service with ALB
│
├── environments/
│ ├── _shared/ # Shared data (account IDs, region lists)
│ ├── dev/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ ├── staging/
│ │ ├── main.tf
│ │ ├── terraform.tfvars
│ │ └── backend.tf
│ └── prod/
│ ├── main.tf
│ ├── terraform.tfvars
│ └── backend.tf
│
├── bootstrap/ # One-time: S3 state bucket + DynamoDB lock table
│ └── main.tf
│
└── .terraform.lock.hcl # Provider version lock file (commit this)Testing Modules
terraform validate — Syntax check
cd modules/serverless-api
terraform init
terraform validateterraform plan — Dry run in real environment
cd environments/dev
terraform plan -out=plan.tfplanTerratest — Integration testing
// test/serverless_api_test.go
package test
import (
"testing"
"github.com/gruntwork-io/terratest/modules/terraform"
"github.com/stretchr/testify/assert"
)
func TestServerlessApiModule(t *testing.T) {
opts := &terraform.Options{
TerraformDir: "../modules/serverless-api",
Vars: map[string]interface{}{
"environment": "test",
"project_name": "learnixo-test",
"lambda_source_dir": "./fixtures/lambda",
},
}
defer terraform.Destroy(t, opts)
terraform.InitAndApply(t, opts)
endpoint := terraform.Output(t, opts, "api_endpoint")
assert.NotEmpty(t, endpoint)
}Common Anti-Patterns to Avoid
Anti-pattern: Hardcoded ARNs and IDs Never hardcode resource ARNs or account IDs. Use data sources or outputs. Your staging and prod accounts have different IDs.
# BAD
role_arn = "arn:aws:iam::123456789:role/my-role"
# GOOD
data "aws_iam_role" "my_role" { name = "my-role" }
role_arn = data.aws_iam_role.my_role.arnAnti-pattern: Giant
main.tfFiles with 1,000+ lines of mixed resources are hard to navigate. Split by concern:lambda.tf,iam.tf,dynamodb.tf,api_gateway.tf.
Anti-pattern: No
.terraform.lock.hclThis file ensures everyone on the team (and CI) uses identical provider versions. Commit it to git.
Anti-pattern:
terraform applyfrom your laptop in production Production applies should only happen through CI/CD. Use Terraform Cloud, Atlantis, or GitHub Actions. Local applies are fine for dev.
Summary
| Practice | Why |
|----------|-----|
| Modules for each stack component | DRY, tested, reusable |
| Pin all versions | Reproducible builds |
| for_each over count | Stable resource keys |
| Separate state per environment | Isolation, safety |
| moved blocks for refactors | No destroy/recreate |
| lifecycle.prevent_destroy | Protect production data |
| default_tags on provider | Consistent billing tags |
| validation on variables | Fail early with clear messages |
Next up: Multi-Environment Strategy — structuring Terraform for dev, staging, and prod with shared modules and per-environment variable files.