I recently built a small production-style AWS environment using Terraform modules to understand better how networking, private EC2 instances, a NAT Gateway, security groups, and an Application Load Balancer work together.
Instead of using a ready-made VPC module, I created the main components myself.
The goal was simple: build something close to a real DevOps setup and understand why each resource exists.
The final architecture looked like this:
The Application Load Balancer is public, while both EC2 instances remain in private subnets without public IP addresses.
Terraform Project Structure
I separated the infrastructure into four modules:
modules/
├── network/
├── security/
├── ec2/
└── alb/
Each module has one clear responsibility.
The network module handles:
VPC
Public subnets
Private subnets
Internet Gateway
NAT Gateway
Route tables
Route table associations
The security module handles the security groups.
The ec2 module creates the application servers.
The alb module creates the Application Load Balancer, target group, listener, and target attachments.
This keeps the root Terraform code clean and makes it easier to understand how each component connects to the others.
VPC and Subnet Design
I created a VPC using:
10.0.0.0/16
Inside the VPC, I created four subnets across two Availability Zones.
Public Subnet A - 10.0.1.0/24
Public Subnet B - 10.0.2.0/24
Private Subnet A - 10.0.11.0/24
Private Subnet B - 10.0.12.0/24
The public subnets are used by resources that need public connectivity, mainly the Application Load Balancer and NAT Gateway.
The application EC2 instances are placed inside the private subnets.
One important thing I learned here is that a subnet is not public just because we name it public.
A subnet becomes public because its route table has a route to an Internet Gateway.
For example:
Destination Target
10.0.0.0/16 local
0.0.0.0/0 Internet Gateway
The public route table is associated with both public subnets.
That gives resources in those subnets a path to the Internet.
Private Subnets and NAT Gateway
The EC2 instances in the private subnets do not have public IP addresses.
But they still need outbound Internet access for things like:
dnf install nginx
They may also need to call APIs, download packages, or reach external services.
For that, I created an Elastic IP and a NAT Gateway inside Public Subnet A.
Then I created a private route table:
Destination Target
10.0.0.0/16 local
0.0.0.0/0 NAT Gateway
Both private subnets are associated with this route table.
The outbound flow looks like this:
Private EC2
↓
Private Route Table
↓
NAT Gateway
↓
Public Subnet
↓
Internet Gateway
↓
Internet
One thing that helped me understand this better is that the NAT Gateway does not directly point to the Internet Gateway.
The NAT Gateway is inside a public subnet.
That public subnet already has:
0.0.0.0/0 → Internet Gateway
So the NAT Gateway uses the routing of that public subnet to reach the Internet.
A simple way to remember it is:
ALB = incoming traffic
NAT = outgoing traffic
Security Groups
I created two security groups:
ALB Security Group
EC2 Security Group
The ALB security group allows HTTP traffic from the Internet:
TCP 80
Source: 0.0.0.0/0
The EC2 security group is more restricted.
Instead of allowing:
TCP 80 from 0.0.0.0/0
I allowed port 80 only from the ALB security group.
So the flow becomes:
Internet
↓
ALB Security Group
↓
Application Load Balancer
↓
EC2 Security Group
↓
Private EC2
This means users cannot directly access the private EC2 instances.
They must go through the load balancer.
A useful rule I learned during this project is:
Route Table = Where can traffic go?
Security Group = Is the traffic allowed?
Both need to be correct.
A security group may allow traffic, but without a valid route the traffic still cannot reach its destination.
Private EC2 Instances
I created two Amazon Linux EC2 instances.
EC2 A → Private Subnet A
EC2 B → Private Subnet B
Both instances use:
associate_public_ip_address = false
So neither one gets a public IP address.
I used Terraform user_data to install Nginx automatically.
Server A returns:
<h1>Application Server A</h1>
Server B returns:
<h1>Application Server B</h1>
Even though the instances are private, the Nginx installation works because outbound traffic goes through the NAT Gateway.
Application Load Balancer
The final main component was the Application Load Balancer.
I created an Internet-facing ALB across both public subnets.
I also created:
Target Group
HTTP Listener on port 80
Target Group Attachment for EC2 A
Target Group Attachment for EC2 B
The incoming flow is:
Client
↓
Internet Gateway
↓
Application Load Balancer
↓
Listener :80
↓
Target Group
↓
EC2 A / EC2 B
The NAT Gateway is not part of this incoming request path.
NAT is only used when the EC2 instances initiate outbound connections.
The target group performs health checks on:
Protocol: HTTP
Port: 80
Path: /
Expected response: 200
After deployment, both instances showed as healthy in the AWS console.
Testing the Load Balancer
Terraform outputs the ALB URL.
I tested it using:
curl $(terraform output -raw website_url)
Running it multiple times returned:
Application Server B
Application Server A
Application Server B
This confirmed that the ALB was successfully distributing requests between both private EC2 instances.
That was the point where the entire architecture came together.
How the Terraform Modules Connect
The most useful part of this project was understanding how Terraform modules pass values between each other.
The network module creates resources and outputs values like:
vpc_id
public_subnet_a_id
public_subnet_b_id
private_subnet_a_id
private_subnet_b_id
The security module receives the VPC ID.
The EC2 module receives the private subnet IDs and EC2 security group ID.
The ALB module receives:
VPC ID
Public subnet IDs
ALB security group ID
EC2 instance IDs
The flow looks like this:
Network
↓
Security
↓
EC2
↓
ALB
The simplest rule I found for understanding Terraform modules is:
The module that creates something outputs its value, and the module that needs it receives that value as an input.
For example:
vpc_id = module.network.vpc_id
Terraform sees that the security module needs an output from the network module.
Because of that reference, Terraform understands that the network resources must exist first.
That is how Terraform builds the dependency graph.
What I Learned
This project made Terraform modules and AWS networking much easier to understand.
The main networking rules are:
Public subnet → Internet Gateway
Private subnet → NAT Gateway
Incoming traffic:
Internet → ALB → Private EC2
Outgoing traffic:
Private EC2 → NAT → Internet
The Terraform side can be summarized as:
Variables → Resources → Outputs → Other Modules
I also learned that memorizing every Terraform argument is not necessary.
What matters more is understanding what each resource depends on.
For example:
Subnet needs VPC ID
Security Group needs VPC ID
EC2 needs Subnet ID and Security Group ID
ALB needs Public Subnet IDs
Target Group Attachment needs EC2 ID
Once those relationships are clear, the Terraform code becomes much easier to read.
Building everything manually was more useful for learning than starting with a large prebuilt VPC module.
For anyone moving beyond basic Terraform examples, this is a good project because it covers VPC networking, routing, NAT Gateway, security groups, private EC2 instances, load balancing, health checks, modules, and dependency management in one setup.
GitHub Link: https://github.com/Shawmeer/terraform-aws-production-lab
Check out more at: https://khanalsamir.com

Top comments (0)