Terraform Advanced Topics
Modules
Modules are reusable, self-contained packages of Terraform configuration.
Module Architecture Diagram
Module Structure
my-module/
main.tf # Resource definitions
variables.tf # Input variables
outputs.tf # Output values
README.md # Documentation
Creating a Module
variables.tf:
variable "vpc_cidr" {
description = "VPC CIDR block"
type = string
}
variable "environment" {
description = "Environment name"
type = string
}
variable "availability_zones" {
description = "AZs for subnets"
type = list(string)
}
main.tf:
resource "aws_vpc" "this" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
tags = {
Name = "${var.environment}-vpc"
Environment = var.environment
}
}
resource "aws_subnet" "public" {
count = length(var.availability_zones)
vpc_id = aws_vpc.this.id
cidr_block = cidrsubnet(var.vpc_cidr, 2, count.index)
availability_zone = var.availability_zones[count.index]
map_public_ip_on_launch = true
tags = {
Name = "${var.environment}-public-subnet-${count.index + 1}"
}
}
outputs.tf:
output "vpc_id" {
description = "VPC ID"
value = aws_vpc.this.id
}
output "subnet_ids" {
description = "Public subnet IDs"
value = aws_subnet.public[*].id
}
output "vpc_cidr" {
description = "VPC CIDR block"
value = aws_vpc.this.cidr_block
}
Using Modules
module "vpc" {
source = "./modules/vpc"
vpc_cidr = "10.0.0.0/16"
environment = "production"
availability_zones = ["us-east-1a", "us-east-1b", "us-east-1c"]
}
resource "aws_security_group" "app" {
vpc_id = module.vpc.vpc_id
ingress {
from_port = 8080
to_port = 8080
protocol = "tcp"
cidr_blocks = ["10.0.0.0/16"]
}
}
Terraform Registry Modules
Use pre-built modules from Terraform Registry:
module "rds" {
source = "terraform-aws-modules/rds/aws"
version = "~> 5.0"
identifier = "mydb"
engine = "postgres"
engine_version = "15"
instance_class = "db.t4g.micro"
db_name = "myapp"
username = "admin"
port = 5432
allocated_storage = 100
skip_final_snapshot = true
skip_final_snapshot_name = "final-snapshot"
}
Workspaces
Workspaces allow managing multiple environments (dev, staging, prod) with separate state.
# Create workspace
terraform workspace new staging
# List workspaces
terraform workspace list
# default
# * staging
# Select workspace
terraform workspace select production
# Delete workspace
terraform workspace delete staging
# Current workspace
terraform workspace show
Using workspaces in configuration:
variable "instance_count" {
type = map(number)
default = {
default = 1
staging = 2
production = 5
}
}
locals {
instance_count = var.instance_count[terraform.workspace]
environment = terraform.workspace
}
resource "aws_instance" "app" {
count = local.instance_count
instance_type = "t2.micro"
tags = {
Environment = local.environment
}
}
Remote State and Backends
Local state files are risky for teams. Remote backends centralize state management.
Terraform CI/CD Pipeline Diagram
S3 Backend Configuration
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "prod/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
Setup script:
#!/bin/bash
# Create S3 bucket
aws s3 mb s3://my-terraform-state-2026 \
--region us-east-1
# Enable versioning
aws s3api put-bucket-versioning \
--bucket my-terraform-state-2026 \
--versioning-configuration Status=Enabled
# Enable encryption
aws s3api put-bucket-encryption \
--bucket my-terraform-state-2026 \
--server-side-encryption-configuration '{
"Rules": [{
"ApplyServerSideEncryptionByDefault": {
"SSEAlgorithm": "AES256"
}
}]
}'
# Block public access
aws s3api put-public-access-block \
--bucket my-terraform-state-2026 \
--public-access-block-configuration \
"BlockPublicAcls=true,IgnorePublicAcls=true,BlockPublicPolicy=true,RestrictPublicBuckets=true"
# Create DynamoDB table for locking
aws dynamodb create-table \
--table-name terraform-locks \
--attribute-definitions AttributeName=LockID,AttributeType=S \
--key-schema AttributeName=LockID,KeyType=HASH \
--billing-mode PAY_PER_REQUEST
State Locking
DynamoDB prevents concurrent modifications:
terraform {
backend "s3" {
bucket = "my-terraform-state"
key = "terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks" # Enables locking
}
}
During apply, Terraform creates a lock entry in DynamoDB.
Terraform Import
Import existing resources into Terraform state.
# Import an existing EC2 instance
terraform import aws_instance.imported i-1234567890abcdef0
# Import with resource address
terraform import aws_security_group.web sg-12345678
# Import into module
terraform import module.vpc.aws_vpc.this vpc-12345678
After import, create the resource block in configuration:
resource "aws_instance" "imported" {
# Configuration will be populated by import
# You may need to fill in missing attributes
}
Provisioners
Provisioners run scripts or commands on resources (use sparingly).
Local Execution
resource "null_resource" "backup" {
provisioner "local-exec" {
command = "aws s3 cp backup.tar.gz s3://my-bucket/"
}
}
Remote Execution
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
key_name = aws_key_pair.deployer.key_name
provisioner "remote-exec" {
inline = [
"sudo apt-get update",
"sudo apt-get install -y nginx",
"sudo systemctl start nginx"
]
connection {
type = "ssh"
user = "ec2-user"
private_key = file("~/.ssh/id_rsa")
host = self.public_ip
}
}
}
Best Practices:
- Prefer user_data, cloud-init, or configuration management
- Provisioners are last resort (indicates design issues)
- Use
on_failure = continueto ignore failures
Dynamic Blocks
Generate multiple blocks dynamically:
resource "aws_security_group" "web" {
name = "dynamic-sg"
dynamic "ingress" {
for_each = [
{ port = 80, protocol = "tcp" },
{ port = 443, protocol = "tcp" },
{ port = 22, protocol = "tcp" }
]
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ["0.0.0.0/0"]
}
}
}
With variables:
variable "ingress_rules" {
type = list(object({
port = number
protocol = string
}))
default = [
{ port = 80, protocol = "tcp" },
{ port = 443, protocol = "tcp" }
]
}
resource "aws_security_group" "web" {
dynamic "ingress" {
for_each = var.ingress_rules
content {
from_port = ingress.value.port
to_port = ingress.value.port
protocol = ingress.value.protocol
cidr_blocks = ["0.0.0.0/0"]
}
}
}
for_each vs count
Both create multiple resources but with different tradeoffs.
count (Index-based)
variable "instance_count" {
type = number
default = 3
}
resource "aws_instance" "server" {
count = var.instance_count
instance_type = "t2.micro"
tags = {
Name = "server-${count.index + 1}"
}
}
output "instance_ids" {
value = aws_instance.server[*].id
}
Problem: Changing count value shifts indices, causing resource replacement.
for_each (Key-based)
variable "instances" {
type = map(object({
instance_type = string
az = string
}))
default = {
web = {
instance_type = "t2.micro"
az = "us-east-1a"
}
app = {
instance_type = "t2.small"
az = "us-east-1b"
}
}
}
resource "aws_instance" "server" {
for_each = var.instances
instance_type = each.value.instance_type
availability_zone = each.value.az
tags = {
Name = each.key
}
}
output "instance_ids" {
value = {for k, v in aws_instance.server : k => v.id}
}
Advantages:
- Keys remain stable when variables change
- Safer refactoring
- Clearer output references
Locals
Local values are convenience definitions for repeated expressions.
locals {
common_tags = {
Environment = var.environment
Project = "CloudCaptain"
ManagedBy = "Terraform"
}
instance_count = var.environment == "production" ? 5 : 1
name_prefix = "${var.environment}-${var.region}"
}
resource "aws_instance" "web" {
count = local.instance_count
tags = merge(local.common_tags, {
Name = "${local.name_prefix}-web-${count.index + 1}"
})
}
Conditional Expressions
resource "aws_instance" "web" {
instance_type = var.environment == "production" ? "t3.large" : "t2.micro"
root_block_device {
volume_size = var.enable_large_disk ? 100 : 20
}
}
locals {
create_rds = var.deploy_database ? 1 : 0
}
resource "aws_db_instance" "main" {
count = local.create_rds
# ...
}
Lifecycle Rules
Control resource lifecycle behavior:
resource "aws_instance" "web" {
ami = "ami-0c55b159cbfafe1f0"
instance_type = "t2.micro"
lifecycle {
# Prevent accidental destruction
prevent_destroy = true
# Create replacement before destroying
create_before_destroy = true
# Ignore changes to tags
ignore_changes = [tags]
# Ignore all computed attributes
ignore_changes = all
# Custom replacement trigger
replace_triggered_by = [aws_ami.ubuntu.id]
}
}
Terraform Cloud/Enterprise
Terraform Cloud is a SaaS platform for team collaboration.
terraform {
cloud {
organization = "my-org"
workspaces {
name = "production"
}
}
}
Features:
- Remote state management
- VCS integration (GitHub, GitLab)
- Run triggers and approvals
- Team management and RBAC
- Cost estimation
Terragrunt
Terragrunt reduces Terraform boilerplate for multi-environment setups.
terragrunt.hcl (root):
remote_state {
backend = "s3"
config = {
bucket = "my-terraform-state"
key = "${get_env("TG_ENVIRONMENT")}/terraform.tfstate"
region = "us-east-1"
encrypt = true
dynamodb_table = "terraform-locks"
}
}
dev/terragrunt.hcl:
include "root" {
path = find_in_parent_folders()
}
inputs = {
environment = "dev"
instance_count = 1
}
Apply across all environments:
cd infrastructure
terragrunt run-all plan
terragrunt run-all apply
Exercises
Exercise 1: Create and Use a VPC Module
Create a reusable VPC module with variables for CIDR block, AZs, and subnet count. Use it in a root module with two workspaces (dev and prod) with different configurations.
Hint: Structure with modules/vpc and separate tfvars per workspace.
Exercise 2: S3 Backend Setup
Create S3 bucket and DynamoDB table for remote state. Migrate your existing configuration to use S3 backend with encryption and locking enabled.
Hint: Use backend configuration block and verify state migration with terraform show.
Exercise 3: Dynamic Security Groups
Create a security group using dynamic blocks that accepts ingress rules from a variable (list of objects with port, protocol, CIDR). Test with 3 different rule sets.
Hint: Use for_each or dynamic ingress blocks.
Exercise 4: for_each Resource Deployment
Create a map variable defining multiple EC2 instances with different types and AZs. Deploy using for_each and output a map of instance names to IDs.
Hint: Use for_each and merge() in outputs.
Exercise 5: Registry Module Integration
Use terraform-aws-modules/vpc/aws from the registry to deploy a full VPC with public and private subnets. Add an RDS instance in the private subnet using locals for configuration.
Hint: Reference registry module and use output values for subnet IDs.
Next Steps:
- Master individual commands with the Cheat Sheet
- Prepare for certification with Exam Prep
- Review common Interview Questions