Build Compute, Load Balancing, and Data Modules
Build Compute, Load Balancing, and Data Modules means turning repeatable AWS service patterns into Terraform modules with narrow inputs, predictable outputs, and clear ownership. In this lesson you will assemble a compute module that runs web instances in an Auto Scaling group, a load balancing module that exposes them through an Application Load Balancer, and a data module that creates a managed database in private subnets.
The outcome is not fewer lines for its own sake. A good module hides implementation detail while still exposing the decisions a platform consumer must make: where the workload runs, how much capacity it needs, which traffic reaches it, and which durable data store it depends on. This chapter connects to the AWS part of the Terraform course because module boundaries are where provider resources, dependency graphs, remote state, and team interfaces meet.
How Terraform Modules Work Internally
A module is a directory of Terraform configuration. The root module is the directory where you run terraform plan or terraform apply. A child module is called with a module block and receives values through input variables. Terraform records child resources in state with addresses such as module.compute.aws_autoscaling_group.this. That address is the identity Terraform uses to compare configuration with real AWS objects.
During planning, Terraform loads every module source, evaluates variables, expands resources and data sources, and builds one dependency graph. Edges come from references. If module.compute receives module.load_balancer.target_group_arn, Terraform knows the target group must exist before the Auto Scaling group can attach to it. Outputs are therefore dependency-carrying interfaces, not just display values.
Provider configuration is inherited by child modules unless you pass aliased providers. Ordinary AWS modules should not quietly assume a region or account. The calling stack decides the provider context; the child module decides which resources it needs inside that context.
Module Interface Anatomy
The public contract of a module is made of variable blocks, output blocks, documented assumptions, and the resource behavior implied by them. Variables describe values a caller can safely choose. Outputs expose values another module or operator needs, such as a load balancer DNS name or database endpoint. Resource names inside the module are private unless you expose them as outputs.
| Module part | Purpose | Example |
|---|---|---|
source |
where Terraform loads the module from | ./modules/compute |
variable |
caller supplied configuration | desired_capacity |
output |
values exported to the caller | target_group_arn |
data |
provider lookup used during planning | aws_ami |
Keep the interface smaller than the implementation. A compute module may expose instance_type, capacity bounds, subnet IDs, and target group ARNs. It should not force the caller to know every launch template field unless callers genuinely need that control.
Example 1: Compute Module
The first example creates web capacity with a launch template and Auto Scaling group. The module takes subnet IDs because the caller owns networking. It takes target group ARNs because the caller may attach the group to one or more load balancers. It outputs the Auto Scaling group name so automation can inspect scaling activity or attach policies later.
variable "name" {
type = string
}
variable "subnet_ids" {
type = list(string)
}
variable "target_group_arns" {
type = list(string)
default = []
}
variable "instance_type" {
type = string
default = "t3.micro"
}
variable "desired_capacity" {
type = number
default = 2
}
variable "min_size" {
type = number
default = 2
}
variable "max_size" {
type = number
default = 4
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
}
resource "aws_launch_template" "this" {
name_prefix = "${var.name}-"
image_id = data.aws_ami.amazon_linux.id
instance_type = var.instance_type
user_data = base64encode(<<-EOT
#!/bin/bash
dnf install -y nginx
systemctl enable --now nginx
echo "${var.name}" > /usr/share/nginx/html/index.html
EOT
)
}
resource "aws_autoscaling_group" "this" {
name = var.name
min_size = var.min_size
max_size = var.max_size
desired_capacity = var.desired_capacity
vpc_zone_identifier = var.subnet_ids
target_group_arns = var.target_group_arns
launch_template {
id = aws_launch_template.this.id
version = "$Latest"
}
tag {
key = "Name"
value = var.name
propagate_at_launch = true
}
}
output "asg_name" {
value = aws_autoscaling_group.this.name
}
Expected behavior: a plan using this module contains an AMI lookup, one launch template, and one Auto Scaling group. When applied in two private subnets with desired_capacity = 2, AWS starts two instances and registers them with any supplied target groups. The user data installs Nginx and writes the service name to the default page, giving a deterministic response body for a smoke test.
Example 2: Load Balancing Module
The second example builds the edge of the service. An Application Load Balancer lives in public subnets, receives HTTP traffic on port 80, and forwards requests to a target group. The target group is separate from the listener so compute can be replaced without replacing the load balancer DNS name.
variable "name" {
type = string
}
variable "vpc_id" {
type = string
}
variable "public_subnet_ids" {
type = list(string)
}
variable "alb_security_group_id" {
type = string
}
resource "aws_lb" "this" {
name = var.name
load_balancer_type = "application"
subnets = var.public_subnet_ids
security_groups = [var.alb_security_group_id]
}
resource "aws_lb_target_group" "web" {
name = "${var.name}-web"
port = 80
protocol = "HTTP"
vpc_id = var.vpc_id
health_check {
path = "/"
matcher = "200"
healthy_threshold = 2
unhealthy_threshold = 2
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.this.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.web.arn
}
}
output "target_group_arn" {
value = aws_lb_target_group.web.arn
}
output "dns_name" {
value = aws_lb.this.dns_name
}
Expected behavior: after apply, the module exports a DNS name and target group ARN. HTTP requests to the DNS name return 503 Service Temporarily Unavailable until healthy targets register. After the compute module attaches instances and health checks pass, the same request returns the Nginx page from one instance. That transition separates load balancer creation from backend readiness.
Example 3: Wiring Modules in the Root Stack
The root module composes the network, load balancer, and compute modules. Networking outputs flow into both service modules. The load balancer target group output flows into compute. The final service URL output is derived from the load balancer DNS name, so callers do not need to understand internal resource names.
module "network" {
source = "./modules/network"
name = "orders-dev"
cidr = "10.40.0.0/16"
}
module "load_balancer" {
source = "./modules/load-balancer"
name = "orders-dev"
vpc_id = module.network.vpc_id
public_subnet_ids = module.network.public_subnet_ids
alb_security_group_id = module.network.alb_security_group_id
}
module "compute" {
source = "./modules/compute"
name = "orders-dev"
subnet_ids = module.network.private_subnet_ids
target_group_arns = [module.load_balancer.target_group_arn]
desired_capacity = 2
min_size = 2
max_size = 4
}
output "service_url" {
value = "http://${module.load_balancer.dns_name}"
}
Expected behavior: Terraform plans the VPC and subnets first, then the load balancer target group, and then the Auto Scaling group attachment to that target group. If only desired_capacity changes from 2 to 3, the plan should update Auto Scaling capacity without replacing the load balancer or network. That small change set is a practical reason to separate stable infrastructure from changing capacity.
Example 4: Data Module for Durable State
A data module has a different risk profile from compute. Instances can usually be replaced; a database carries durable state. The module below creates a subnet group, password, and PostgreSQL instance. It enables deletion protection and requires a final snapshot on deletion by setting skip_final_snapshot = false. Those defaults slow down destructive changes, which is appropriate for stateful infrastructure.
variable "name" {
type = string
}
variable "subnet_ids" {
type = list(string)
}
variable "db_security_group_id" {
type = string
}
variable "database_name" {
type = string
default = "orders"
}
resource "aws_db_subnet_group" "this" {
name = var.name
subnet_ids = var.subnet_ids
}
resource "aws_db_instance" "this" {
identifier = var.name
allocated_storage = 20
engine = "postgres"
instance_class = "db.t4g.micro"
db_name = var.database_name
username = "app"
password = random_password.db.result
db_subnet_group_name = aws_db_subnet_group.this.name
vpc_security_group_ids = [var.db_security_group_id]
skip_final_snapshot = false
deletion_protection = true
}
resource "random_password" "db" {
length = 24
special = true
}
output "endpoint" {
value = aws_db_instance.this.address
}
Expected behavior: a plan creates a DB subnet group, a generated password stored in Terraform state, and an RDS instance reachable through the supplied database security group. The output exposes the database address, not the password. Because the password is still in state, the backend that stores state must be encrypted and access-controlled.
Design Choices and Trade-Offs
Module size is the first trade-off. A single service module that creates networking, load balancing, compute, and data is easy to call but hard to evolve. A smaller compute module is more reusable but asks the root module to wire more outputs. In team environments, smaller modules usually age better because network, runtime, and database ownership often belong to different groups.
Another decision is whether to pass IDs or discover resources with data sources. Passing subnet_ids makes dependencies explicit and works well when Terraform created the network. A data source lookup can be convenient for existing infrastructure, but it can fail when tags are ambiguous or when the selected resource changes outside Terraform. Prefer direct outputs between Terraform-managed modules when possible.
Defaults need discipline. A development module can default to tiny instances, but production capacity, backup retention, deletion protection, and multi-zone placement should be deliberate caller choices. Use validation blocks for hard rules, such as minimum capacity not exceeding maximum capacity, and use tests or policy checks for environment-specific rules.
Failure Modes and Troubleshooting
Symptom: Terraform reports that a module output is unknown or unsupported. Cause: the child module does not define that output, or the root module references the wrong module name. Diagnostic steps: inspect outputs.tf, run terraform validate, and check the exact address in the error. Correction: add the intended output or update the root reference.
Symptom: the ALB DNS name works but returns 503. Cause: the load balancer exists, but no healthy instances are registered in the target group. Diagnostic steps: check target health, confirm the Auto Scaling group has the target group ARN, verify instance security group ingress from the ALB security group, and read user data logs. Correction: fix the security group rule, health check path, or user data failure.
Symptom: Terraform wants to replace a database after a variable change. Cause: some RDS arguments force replacement because AWS cannot modify them in place. Diagnostic steps: read the plan line that says forces replacement, identify the changed argument, and compare it with the module input that produced it. Correction: use a supported in-place setting, create a snapshot restore migration plan, or reject that input change for existing databases.
Security, Performance, and Reliability
Compute modules shape blast radius. Place instances in private subnets, expose traffic through the load balancer, and pass security group IDs instead of opening broad CIDR ranges inside the module. Load balancer modules should make health checks explicit because they control whether bad instances receive traffic. Data modules must treat state as sensitive because generated passwords and database identifiers are written to Terraform state.
Performance choices also belong at module boundaries. Scaling limits, health check thresholds, database class, and storage size should be visible inputs or documented defaults. Reliability improves when replacement-sensitive resources are separated from frequently changed resources. Changing web capacity should not be coupled to changing database subnet groups.
Hands-On Lab
Prerequisites: Terraform installed, AWS credentials for a sandbox account, permission to create VPC, EC2, ALB, Auto Scaling, RDS, and security group resources, and a backend you can safely destroy. Use a non-production account because the lab creates billable resources.
- Create directories named
modules/network,modules/load-balancer,modules/compute, andmodules/data. Implement the network module or use an existing course network module that outputs VPC ID, public subnet IDs, private subnet IDs, and security group IDs. - Add the compute module from Example 1 and the load balancer module from Example 2. Wire them with the root module from Example 3.
- Run
terraform init, thenterraform validate. Validation should complete without syntax or provider schema errors. - Run
terraform plan -out=tfplan. Verify that the plan creates a load balancer, listener, target group, launch template, and Auto Scaling group. Confirm that database resources are absent unless you also call the data module. - Apply with
terraform apply tfplan. After apply, request theservice_urloutput withcurl. The first response may be 503 while health checks warm up; after targets are healthy, the response body should includeorders-dev. - Change
desired_capacityfrom 2 to 3 and run another plan. Verify that the planned change affects Auto Scaling capacity and does not replace the load balancer. - For cleanup, run
terraform destroy. If you added the data module, either disable deletion protection through an intentional plan before destroy or keep the database for a snapshot and recovery exercise.
Verification with Terraform Test
Terraform test files can check module contracts before a human reviews the full plan. This example verifies that the composed stack exposes a compute output and that the load balancer DNS name follows the expected service naming pattern.
run "root_module_wires_compute_to_alb" {
command = plan
assert {
condition = length(module.compute.asg_name) > 0
error_message = "compute module did not expose the Auto Scaling group name"
}
assert {
condition = startswith(module.load_balancer.dns_name, "orders-dev")
error_message = "load balancer DNS name is not derived from the expected module"
}
}
Expected behavior: terraform test runs a plan and evaluates the assertions. A missing output fails before apply, which is cheaper than discovering the mistake after resources have been created. In a real repository, add tests for invalid capacity ranges and for required tags on every module.
Assessment Exercises
- A team wants the compute module to create its own VPC because it would make the root module shorter. Explain what ownership and reuse problems this creates, and propose a cleaner interface.
- Your plan shows that changing a load balancer name will replace the ALB. What user-visible effects can that have, and how would you reduce risk before applying?
- Design a validation rule for
min_size,desired_capacity, andmax_size. Which invalid combinations should be rejected before planning remote changes? - A database password is generated by Terraform. Where is that password stored, who can read it, and what backend controls are required?
- Compare passing subnet IDs from a network module with discovering subnets by tag inside each child module. Which approach gives a clearer dependency graph, and why?
Summary
Compute, load balancing, and data modules turn AWS infrastructure into composable Terraform contracts. The compute module owns replaceable runtime capacity, the load balancer module owns traffic entry and health routing, and the data module owns durable state with stricter deletion behavior. Design each interface around caller decisions, pass outputs to preserve graph dependencies, test the contract, and troubleshoot by following module addresses from root input to provider resource.
