Three-Tier Web App with Terraform and Ansible
Provision and deploy a three-tier AWS app using Terraform, Ansible, and GitHub Actions.
Introduction
30 Second Summary
Every company that runs a web application eventually faces the same challenge: how do you reliably build, configure, and deploy infrastructure without clicking through consoles or running manual scripts that break in unpredictable ways?
In this project, you will build a complete three-tier web application on AWS, fully automated from code to running app. You will define your infrastructure in Terraform, configure your servers with Ansible, and wire it all together with a GitHub Actions pipeline that deploys everything on a single push to main.
What You'll Build
A production-style three-tier AWS architecture with an Application Load Balancer, EC2 application servers, and an RDS MySQL database, all provisioned and deployed automatically through a CI/CD pipeline.
By the end of this project, you'll have:
- A VPC with public and private subnets, security group chaining, and an ALB routing traffic to your EC2 instance.
- Modular Terraform configuration with remote state that defines your entire networking, compute, and database layer.
- An Ansible playbook that automatically installs Nginx, Node.js, and deploys your app to the server.
- A GitHub Actions workflow that orchestrates Terraform and Ansible end-to-end on every push.
- Secret Mission: Upgrade your pipeline to use GitHub OIDC for keyless AWS authentication, eliminating stored credentials entirely.
Are there any prerequisites?
This project assumes a basic understanding of Terraform (provisioning resources, running terraform plan/terraform apply), AWS fundamentals (EC2, VPC basics), and comfort with the Linux command line.
You will also need Terraform, Ansible, the AWS CLI (configured with credentials), Git, a GitHub account, and a code editor installed on your machine.
Set Up the Project and Remote State
Before you can automate infrastructure with Terraform and Ansible, you need a home for all your configuration files and a way for your CI/CD pipeline to track what infrastructure already exists.
In this step, you'll create a GitHub repository with a clean directory structure, set up an S3 bucket for Terraform's remote state, and store your AWS credentials as GitHub secrets.
In this step, get ready to:
- Create a GitHub repository and local directory structure.
- Create an S3 bucket for Terraform remote state.
- Add AWS credentials as GitHub repository secrets.
Create the GitHub repository and directory structure
Your pipeline needs a Git repository to store the Terraform configs, Ansible playbooks, application code, and the workflow definition.
- Open your browser and go to github.com/new.
- Enter three-tier-infra as the repository name.
- Select **Public** visibility.
- Check the box for Add a README file.
- Click Create repository.
Now clone the repository to your local machine and create the directory structure.
- Open your terminal by pressing Cmd+Space (macOS) or the Windows key (Windows), then type Terminal and press Enter.
- Move to your Desktop by running this command:
cd ~/Desktop
- Clone your repository by running this command:
git clone [[YOUR_REPO_URL="https://github.com/YOUR-USERNAME/three-tier-infra.git"]]
Where do I find my repo URL?
On your new repository's GitHub page, click the green Code button and copy the HTTPS URL.
- Move into the cloned repository and create the project directories by running these commands:
cd three-tier-infra
mkdir -p terraform ansible app scripts .github/workflows
What does this directory structure do?
Each folder has a specific role in the pipeline.
- terraform/ holds all infrastructure definitions (VPC, EC2, RDS, ALB).
- ansible/ holds server configuration playbooks.
- app/ holds the Node.js application code.
- scripts/ holds helper scripts like inventory generation.
- .github/workflows/ holds the GitHub Actions pipeline definition.
- Confirm the directories exist by running:
ls
You should see ansible, app, scripts, and terraform in the output.
Don't see the directories?
Make sure you ran cd three-tier-infra before running mkdir. If you see the README file but no folders, run the mkdir -p command again from inside the three-tier-infra folder.
help me debug why my project directories weren't created
Create the S3 bucket for Terraform remote state
Terraform tracks what infrastructure it has created in a state file. When your GitHub Actions pipeline runs Terraform, it needs access to this state file so it knows what already exists.
Storing state in an S3 bucket makes it accessible from any machine, including CI/CD runners.
- Create your S3 state bucket by running this command:
aws s3 mb s3://[[BUCKET_NAME="yourname-three-tier-tfstate"]] --region us-east-1
Why does my bucket name need to be unique?
S3 bucket names are globally unique across all AWS accounts. Include your name or a random string to avoid collisions (e.g., jsmith-three-tier-tfstate).
You should see make_bucket followed by your bucket name in the terminal output.
Seeing a BucketAlreadyExists error?
Someone else already owns that bucket name. Try adding more unique characters to your bucket name (your initials, a date, or random numbers).
If you see an "access denied" or "credentials" error, verify your AWS CLI is configured by running aws sts get-caller-identity. This should show your AWS account ID.
help me fix my S3 bucket creation error
Why not DynamoDB state locking?
In team environments, you'd add a DynamoDB table to prevent two people from modifying state simultaneously. Since you're the only person running this pipeline, the locking table is unnecessary overhead for this project.
Add AWS credentials as GitHub repository secrets
Your GitHub Actions pipeline needs AWS credentials to provision infrastructure. GitHub repository secrets store sensitive values securely and inject them into workflow runs without exposing them in code.
- In your browser, navigate to your three-tier-infra repository on GitHub.
- Click Settings in the top navigation bar of your repository.
- In the left sidebar, click Secrets and variables, then click Actions.
- Click New repository secret.
- Enter AWS_ACCESS_KEY_ID as the name.
- Paste your AWS access key ID in the Secret field.
- Click Add secret.
- Click New repository secret again.
- Enter AWS_SECRET_ACCESS_KEY as the name.
- Paste your AWS secret access key in the Secret field.
- Click Add secret.
Where do I find my AWS credentials?
These are the same access key ID and secret access key you used to configure the AWS CLI. If you need new ones, go to the AWS Console, click your username in the top right, select Security credentials, then create an access key under Access keys.
You should now see both AWS_ACCESS_KEY_ID and AWS_SECRET_ACCESS_KEY listed on the Actions secrets page.
Don't see the Settings tab?
The Settings tab only appears if you are the repository owner. Make sure you are viewing your own repository (not a fork of someone else's).
help me add secrets to my GitHub repository
Your project foundation is in place. You have a repository structure, a remote state backend, and pipeline credentials ready. Next, you'll write the Terraform configuration that defines your entire networking layer.
Build the Terraform Networking Layer
Your project structure and remote state backend are in place. Now it's time to define the actual infrastructure.
A three-tier architecture isolates each layer (presentation, application, data) into its own network segment. In this step, you'll codify the entire networking foundation in Terraform and lock down traffic between tiers using security group chaining.
In this step, get ready to:
- Configure the Terraform providers and S3 backend.
- Define the VPC, subnets, internet gateway, and route tables.
- Create security groups that enforce tier-to-tier access control.
Create the providers and variables files
Every Terraform project starts with a provider configuration that tells Terraform which cloud to talk to and where to store its state.
- Create a new file called providers.tf inside your terraform/ folder and paste the following code:
terraform {
required_version = ">= 1.5.0"
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 5.0"
}
tls = {
source = "hashicorp/tls"
version = "~> 4.0"
}
}
backend "s3" {
bucket = "[[STATE_BUCKET=\"your-unique-bucket-name-tfstate\"]]"
key = "three-tier/terraform.tfstate"
region = "us-east-1"
}
}
provider "aws" {
region = var.aws_region
}
What does this code do?
- required_providers pins the AWS provider to version ~> 5.0 and adds the TLS provider (you'll use this later to generate an SSH key pair).
- The backend "s3" block tells Terraform to store its state file in the S3 bucket you created in Step 1. This makes state accessible from your CI/CD pipeline.
- The provider "aws" block configures the region using a variable, keeping the configuration flexible.
- Save providers.tf.
Next, you need a variables file so the rest of your configuration can reference reusable values like the project name and VPC CIDR.
- Create a new file called variables.tf inside the terraform/ folder and paste the following code:
variable "aws_region" {
description = "AWS region to deploy resources"
type = string
default = "us-east-1"
}
variable "project_name" {
description = "Project name used for resource naming"
type = string
default = "three-tier-app"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "db_username" {
description = "Database master username"
type = string
default = "admin"
}
variable "db_password" {
description = "Database master password"
type = string
sensitive = true
}
What does this code do?
- project_name is used to tag and name every resource consistently. You'll see it in all the resource names throughout this project.
- vpc_cidr defines the overall IP address range for your network (10.0.0.0/16 gives you 65,536 addresses).
- db_password is marked sensitive = true so Terraform redacts it from console output. It has no default because you'll provide it at apply time.
- Save variables.tf.
- Confirm both files exist by running this from your terraform/ directory:
ls *.tf
You should see providers.tf and variables.tf in the output.
Not seeing both files?
- Make sure you're inside the terraform/ directory, not the project root. Run cd terraform if needed.
- Check that you saved the files with the .tf extension and not .tf.txt or similar.
Help me find my Terraform files
Define the VPC, subnets, and routing
The VPC is your isolated network in AWS. Inside it, you'll create four subnets split across two availability zones for high availability.
- Create a new file called networking.tf inside terraform/ and paste the VPC and subnet definitions:
# Look up available AZs in the region
data "aws_availability_zones" "available" {
state = "available"
}
# The main VPC for all three tiers
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
# Public subnet 1 - ALB and EC2 (AZ a)
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
# Public subnet 2 - ALB requires subnets in 2 AZs
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
# Private subnet 1 - RDS (AZ a)
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "${var.project_name}-private-1"
}
}
# Private subnet 2 - RDS requires subnets in 2 AZs
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
tags = {
Name = "${var.project_name}-private-2"
}
}
What does this code do?
- The data "aws_availability_zones" block dynamically fetches the AZ names in your region so you don't hardcode them.
- Public subnets (10.0.1.0/24, 10.0.2.0/24) host the ALB and EC2 instances. They assign public IPs automatically via map_public_ip_on_launch.
- Private subnets (10.0.3.0/24, 10.0.4.0/24) host the RDS database. They have no public IP and no internet route, keeping the database isolated.
The public subnets need a route to the internet. Without an Internet Gateway and a route table, traffic can't flow in or out.
- Add the following code to the bottom of networking.tf, below the subnet definitions:
# Gateway that connects the VPC to the internet
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
# Route table that sends all traffic (0.0.0.0/0) through the IGW
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
# Associate public subnet 1 with the public route table
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
# Associate public subnet 2 with the public route table
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
What does this code do?
- The Internet Gateway is the door between your VPC and the public internet.
- The route table has a single rule: send all traffic (0.0.0.0/0) to the IGW. Only the public subnets get this route.
- Private subnets have no route table association to the IGW, so they remain unreachable from the internet.
- Save networking.tf.
Unsure about the subnet layout?
Double check that public subnets use 10.0.1.0/24 and 10.0.2.0/24, while private subnets use 10.0.3.0/24 and 10.0.4.0/24. Mixing these up causes the ALB or RDS to land in the wrong network segment.
Help me understand the subnet layout
✔️ Awesome, I've got everything!
Great. Double check you've saved your file before moving on.
ⓧ I'd like to double check the full code
data "aws_availability_zones" "available" {
state = "available"
}
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_hostnames = true
enable_dns_support = true
tags = {
Name = "${var.project_name}-vpc"
}
}
resource "aws_subnet" "public_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.1.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-1"
}
}
resource "aws_subnet" "public_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.2.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
map_public_ip_on_launch = true
tags = {
Name = "${var.project_name}-public-2"
}
}
resource "aws_subnet" "private_1" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.3.0/24"
availability_zone = data.aws_availability_zones.available.names[0]
tags = {
Name = "${var.project_name}-private-1"
}
}
resource "aws_subnet" "private_2" {
vpc_id = aws_vpc.main.id
cidr_block = "10.0.4.0/24"
availability_zone = data.aws_availability_zones.available.names[1]
tags = {
Name = "${var.project_name}-private-2"
}
}
resource "aws_internet_gateway" "main" {
vpc_id = aws_vpc.main.id
tags = {
Name = "${var.project_name}-igw"
}
}
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.main.id
}
tags = {
Name = "${var.project_name}-public-rt"
}
}
resource "aws_route_table_association" "public_1" {
subnet_id = aws_subnet.public_1.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table_association" "public_2" {
subnet_id = aws_subnet.public_2.id
route_table_id = aws_route_table.public.id
}
Create security groups for tier isolation
Security groups act as firewalls for each tier. The key pattern here is security group chaining: instead of allowing traffic from IP ranges, each group references the previous tier's group by ID.
This means the database only accepts connections from EC2 instances that belong to the app security group. Even if someone gets a database IP, they can't connect without being in the right group.
- Create a new file called security.tf inside terraform/ and paste the following code:
# ALB security group - accepts HTTP traffic from the internet
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
# App security group - accepts traffic ONLY from the ALB SG
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for the application EC2 instances"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
ingress {
description = "SSH for Ansible"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-app-sg"
}
}
# DB security group - accepts MySQL ONLY from the App SG
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for RDS database"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from App tier"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-db-sg"
}
}
How does security group chaining work?
Notice the security_groups field in the app and DB ingress rules. Instead of allowing an IP range (cidr_blocks), they reference another security group's ID.
- ALB SG: allows HTTP port 80 from anywhere on the internet. This is the only group exposed publicly.
- App SG: allows HTTP only from resources in the ALB SG. Also opens SSH port 22 so Ansible can configure the server.
- DB SG: allows MySQL port 3306 only from resources in the App SG. The database is completely unreachable from the internet.
- Save security.tf.
- Confirm all four Terraform files exist by running:
ls terraform/
You should see networking.tf, providers.tf, security.tf, and variables.tf listed.
Missing a file?
- Make sure you're running the command from the project root (the three-tier-infra folder), not from inside the terraform/ directory. If you're already inside terraform/, use ls *.tf instead.
- Verify each file has the .tf extension and is saved in the terraform/ subdirectory, not the project root.
Help me find my missing Terraform files
✔️ Awesome, I've got everything!
Great. Make sure all files are saved before continuing.
ⓧ I'd like to double check the full code
resource "aws_security_group" "alb" {
name = "${var.project_name}-alb-sg"
description = "Security group for the Application Load Balancer"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from anywhere"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-alb-sg"
}
}
resource "aws_security_group" "app" {
name = "${var.project_name}-app-sg"
description = "Security group for the application EC2 instances"
vpc_id = aws_vpc.main.id
ingress {
description = "HTTP from ALB"
from_port = 80
to_port = 80
protocol = "tcp"
security_groups = [aws_security_group.alb.id]
}
ingress {
description = "SSH for Ansible"
from_port = 22
to_port = 22
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-app-sg"
}
}
resource "aws_security_group" "db" {
name = "${var.project_name}-db-sg"
description = "Security group for RDS database"
vpc_id = aws_vpc.main.id
ingress {
description = "MySQL from App tier"
from_port = 3306
to_port = 3306
protocol = "tcp"
security_groups = [aws_security_group.app.id]
}
egress {
from_port = 0
to_port = 0
protocol = "-1"
cidr_blocks = ["0.0.0.0/0"]
}
tags = {
Name = "${var.project_name}-db-sg"
}
}
Your entire networking foundation is now defined as code. Next up, you'll add the compute and data layers that sit on top of this network.
Build the Terraform Compute and Data Layers
Your VPC networking and security groups form the skeleton of the three-tier architecture. The security groups define who can talk to whom, but there's nothing to receive traffic yet.
In this step, you'll create the actual compute resources that sit inside those security boundaries. An Application Load Balancer will accept internet traffic, an EC2 instance will host your app, and an RDS database will persist data.
In this step, get ready to:
- Define the Application Load Balancer with health checks and traffic routing.
- Create the EC2 compute instance with a Terraform-managed SSH key pair.
- Configure the RDS MySQL database and export Terraform outputs.
Define the Application Load Balancer
The ALB is the entry point for all internet traffic hitting your application. It receives HTTP requests and forwards them to healthy EC2 instances in your target group.
- Create a new file called alb.tf inside your terraform/ directory.
- Add the following code to define the load balancer, target group, listener, and target group attachment:
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
tags = {
Name = "${var.project_name}-alb"
}
}
# Target group tells the ALB where to send traffic
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
# ALB checks this endpoint to confirm the app is healthy
health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = "/health"
port = "traffic-port"
timeout = 5
unhealthy_threshold = 3
}
tags = {
Name = "${var.project_name}-tg"
}
}
# Listener binds port 80 on the ALB to forward traffic to the target group
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
# Attach the EC2 instance to the target group
resource "aws_lb_target_group_attachment" "app" {
target_group_arn = aws_lb_target_group.app.arn
target_id = aws_instance.app.id
port = 80
}
What does this code do?
This file creates the presentation tier of your architecture with four tightly connected resources.
- The aws_lb is an internet-facing ALB that sits across both public subnets for high availability.
- The aws_lb_target_group defines where the ALB sends traffic. The health check hits /health every 30 seconds and expects a 200 response.
- The aws_lb_listener binds port 80 on the ALB and forwards requests to the target group.
- The aws_lb_target_group_attachment registers the EC2 instance (which you'll define next) as a target.
- Save alb.tf.
You won't see any output yet. The ALB references aws_instance.app which you'll define in the next chunk.
Seeing a red underline or error in your editor?
If your editor highlights aws_instance.app.id as an error, that's expected. The compute.tf file that defines this resource doesn't exist yet. The error disappears once you create it in the next substep.
help me fix a reference error in my Terraform ALB file
Create the EC2 compute instance
The application tier needs a server to run your Node.js app. Terraform will generate an SSH key pair so that Ansible can connect to the instance later without you managing keys manually.
- Create a new file called compute.tf inside your terraform/ directory.
- Add the following code to define the AMI lookup, SSH key, and EC2 instance:
# Find the latest Amazon Linux 2023 AMI automatically
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
# Generate an SSH key pair within Terraform
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
# Register the public key with AWS
resource "aws_key_pair" "deployer" {
key_name = "${var.project_name}-key"
public_key = tls_private_key.ssh.public_key_openssh
}
# The application server in the first public subnet
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
key_name = aws_key_pair.deployer.key_name
vpc_security_group_ids = [aws_security_group.app.id]
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-app-server"
}
}
What does this code do?
- The data "aws_ami" block looks up the latest Amazon Linux 2023 image so you never hardcode an AMI ID that goes stale.
- The tls_private_key resource generates a 4096-bit RSA key pair. Terraform stores the private key in state, making it available as an output for Ansible to use.
- The aws_key_pair uploads the public half to AWS so EC2 can authorize SSH logins.
- The aws_instance launches a t2.micro (free-tier eligible) in your first public subnet, attached to the app security group.
Why generate the SSH key in Terraform?
Keeping the key lifecycle inside Terraform means the private key is available as a Terraform output. Your GitHub Actions pipeline can extract it automatically and pass it to Ansible without you manually creating or uploading keys.
- Save compute.tf.
The editor error in alb.tf should now disappear since aws_instance.app exists.
Still seeing a reference error?
Make sure your file is named exactly compute.tf (not compute.tf.txt or Compute.tf). Terraform only processes files ending in .tf within the same directory.
help me fix my Terraform file reference issue
Add the database layer and outputs
The data tier isolates your database in private subnets where only the application security group can reach it. You'll also define Terraform outputs so the pipeline can extract connection details for Ansible.
- Create a new file called database.tf inside your terraform/ directory.
- Add the following code to define the DB subnet group and RDS instance:
# Group the private subnets so RDS can span availability zones
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_1.id, aws_subnet.private_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
# RDS MySQL instance in the private subnets
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp2"
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
skip_final_snapshot = true
publicly_accessible = false
tags = {
Name = "${var.project_name}-db"
}
}
What does this code do?
- The aws_db_subnet_group tells RDS to place the database across your two private subnets for availability zone coverage.
- The aws_db_instance creates a MySQL 8.0 database on a db.t3.micro (free-tier eligible for the first 12 months).
- Setting publicly_accessible = false ensures the database has no public IP. Only resources in the app security group can connect on port 3306.
- The skip_final_snapshot = true setting lets Terraform destroy the database without requiring a final backup. This keeps teardown simple for a learning project.
- Save database.tf.
Now you need outputs so that Terraform can expose connection details to Ansible after provisioning.
- Create a new file called outputs.tf inside your terraform/ directory.
- Add the following output definitions:
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "app_instance_public_ip" {
description = "Public IP of the application EC2 instance"
value = aws_instance.app.public_ip
}
output "rds_endpoint" {
description = "RDS database endpoint"
value = aws_db_instance.main.endpoint
}
# Marked sensitive so Terraform won't print it in logs
output "private_key" {
description = "SSH private key for EC2 access"
value = tls_private_key.ssh.private_key_pem
sensitive = true
}
Why are outputs important here?
Outputs are how Terraform passes information to external tools. Your CI/CD pipeline will run terraform output -json to extract the EC2 IP, RDS endpoint, and SSH key, then feed those values into Ansible's inventory.
The sensitive = true flag on the private key prevents Terraform from printing it in plan output. You can still extract it programmatically with terraform output -raw private_key.
- Save outputs.tf.
One final file remains. Terraform variables need actual values provided at runtime, and a .tfvars file is the standard way to supply them.
- Create a new file called terraform.tfvars inside your terraform/ directory.
- Add the following variable values:
project_name = "three-tier-app"
aws_region = "us-east-1"
db_username = "admin"
db_password = "ChangeMe123!"
What does this file do?
Terraform automatically loads any file named terraform.tfvars in the working directory. These values fill in the variables you declared in variables.tf. In a real project, you would never commit database passwords to version control. For this learning project, the simple password keeps things moving.
- Save terraform.tfvars.
- Confirm all your new files exist by running this from the root of your project:
ls terraform/
You should see: alb.tf, compute.tf, database.tf, networking.tf, outputs.tf, providers.tf, security.tf, terraform.tfvars, and variables.tf.
Missing a file?
Make sure you created each file inside the terraform/ directory (not the project root). If a file landed in the wrong place, move it into terraform/ and re-run ls terraform/.
help me find my missing Terraform files
✔️ Awesome, I've got everything!
Great. Double check you've saved all files before moving on.
ⓧ I'd like to double check the full code
Here are all four new files in full. Compare your code against these:
resource "aws_lb" "main" {
name = "${var.project_name}-alb"
internal = false
load_balancer_type = "application"
security_groups = [aws_security_group.alb.id]
subnets = [aws_subnet.public_1.id, aws_subnet.public_2.id]
tags = {
Name = "${var.project_name}-alb"
}
}
resource "aws_lb_target_group" "app" {
name = "${var.project_name}-tg"
port = 80
protocol = "HTTP"
vpc_id = aws_vpc.main.id
health_check {
enabled = true
healthy_threshold = 2
interval = 30
matcher = "200"
path = "/health"
port = "traffic-port"
timeout = 5
unhealthy_threshold = 3
}
tags = {
Name = "${var.project_name}-tg"
}
}
resource "aws_lb_listener" "http" {
load_balancer_arn = aws_lb.main.arn
port = 80
protocol = "HTTP"
default_action {
type = "forward"
target_group_arn = aws_lb_target_group.app.arn
}
}
resource "aws_lb_target_group_attachment" "app" {
target_group_arn = aws_lb_target_group.app.arn
target_id = aws_instance.app.id
port = 80
}
data "aws_ami" "amazon_linux" {
most_recent = true
owners = ["amazon"]
filter {
name = "name"
values = ["al2023-ami-*-x86_64"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "tls_private_key" "ssh" {
algorithm = "RSA"
rsa_bits = 4096
}
resource "aws_key_pair" "deployer" {
key_name = "${var.project_name}-key"
public_key = tls_private_key.ssh.public_key_openssh
}
resource "aws_instance" "app" {
ami = data.aws_ami.amazon_linux.id
instance_type = "t2.micro"
key_name = aws_key_pair.deployer.key_name
vpc_security_group_ids = [aws_security_group.app.id]
subnet_id = aws_subnet.public_1.id
tags = {
Name = "${var.project_name}-app-server"
}
}
resource "aws_db_subnet_group" "main" {
name = "${var.project_name}-db-subnet-group"
subnet_ids = [aws_subnet.private_1.id, aws_subnet.private_2.id]
tags = {
Name = "${var.project_name}-db-subnet-group"
}
}
resource "aws_db_instance" "main" {
identifier = "${var.project_name}-db"
engine = "mysql"
engine_version = "8.0"
instance_class = "db.t3.micro"
allocated_storage = 20
storage_type = "gp2"
db_name = "appdb"
username = var.db_username
password = var.db_password
db_subnet_group_name = aws_db_subnet_group.main.name
vpc_security_group_ids = [aws_security_group.db.id]
skip_final_snapshot = true
publicly_accessible = false
tags = {
Name = "${var.project_name}-db"
}
}
output "alb_dns_name" {
description = "DNS name of the Application Load Balancer"
value = aws_lb.main.dns_name
}
output "app_instance_public_ip" {
description = "Public IP of the application EC2 instance"
value = aws_instance.app.public_ip
}
output "rds_endpoint" {
description = "RDS database endpoint"
value = aws_db_instance.main.endpoint
}
output "private_key" {
description = "SSH private key for EC2 access"
value = tls_private_key.ssh.private_key_pem
sensitive = true
}
project_name = "three-tier-app"
aws_region = "us-east-1"
db_username = "admin"
db_password = "ChangeMe123!"
Your entire Terraform configuration is complete. Next up, you'll run terraform plan and terraform apply to provision all of these resources on AWS and verify the infrastructure is running.
Apply Terraform and Verify Infrastructure
You have a complete set of Terraform configuration files that describe your entire three-tier architecture. The code defines every resource from the VPC to the RDS database.
Now it is time to bring that infrastructure to life. In this step, you will initialize Terraform, review the execution plan, provision all AWS resources, and confirm you can reach the EC2 instance over SSH.
In this step, get ready to:
- Initialize Terraform and review the infrastructure plan.
- Apply the configuration to provision all AWS resources.
- Extract the SSH key and verify connectivity to the EC2 instance.
Initialize Terraform and review the plan
Before Terraform can create any resources, it needs to download the required providers and connect to the S3 backend where it stores state.
- Navigate to the terraform/ directory by running this command:
cd terraform
- Initialize the Terraform working directory by running:
terraform init
You should see output confirming the S3 backend was configured and providers were installed. Look for "Terraform has been successfully initialized" at the end.
Seeing a backend configuration error?
Make sure the S3 bucket name in providers.tf matches the bucket you created in Step 1. The bucket must exist before Terraform can use it as a backend.
Also confirm your AWS CLI credentials are configured and have permission to access the bucket.
Why is terraform init failing for me?
- Generate an execution plan to preview what Terraform will create by running:
terraform plan
What does terraform plan show?
The plan output shows every resource Terraform intends to create, modify, or destroy. Since this is a fresh deployment, everything will show as "will be created" with a green + prefix.
Review the plan and confirm it lists: 1 VPC, 4 subnets, 1 Internet Gateway, 1 ALB, 1 EC2 instance, 1 RDS instance, and 3 security groups. The total resource count will be higher because of supporting resources like route tables, target groups, and key pairs.
At the bottom of the plan, you should see a summary line like "Plan: 20 to add, 0 to change, 0 to destroy" (the exact count may vary slightly).
Apply the Terraform configuration
The plan confirms Terraform understands your intent. Now you will apply it to actually provision the AWS resources.
- Provision all resources by running this command:
terraform apply -auto-approve
Why -auto-approve?
Normally, terraform apply prompts you to type "yes" before making changes. The -auto-approve flag skips that confirmation. You already reviewed the plan in the previous command, so you know what will be created.
This takes 5-10 minutes
The RDS database instance takes the longest to provision. Do not interrupt the process. Terraform will show progress updates as each resource is created.
Once the apply completes, Terraform prints the output values defined in outputs.tf. You should see three values (the private key is hidden because it is marked sensitive):
- alb_dns_name showing your ALB endpoint (something like three-tier-app-alb-123456.us-east-1.elb.amazonaws.com).
- app_instance_public_ip showing the EC2 public IP address.
- rds_endpoint showing the database connection string.
Apply failed with an error?
If you see a "limit exceeded" error, your AWS account may have reached the default VPC or Elastic IP limit for the region. Try a different region or request a limit increase in the AWS console.
If you see "Error creating DB Instance", check that your db_password in terraform.tfvars meets MySQL requirements (at least 8 characters, no special characters like @ or /).
Help me debug my terraform apply error.
Extract the SSH key and test connectivity
Terraform generated an SSH key pair during provisioning. You need to extract the private key so Ansible can use it later to connect to the EC2 instance and configure it.
- Extract the private key from Terraform output and save it to the ansible/ directory by running:
terraform output -raw private_key > ../ansible/key.pem && chmod 600 ../ansible/key.pem
What does this command do?
terraform output -raw private_key extracts the PEM-formatted private key from Terraform state. The > ../ansible/key.pem redirects it into a file.
chmod 600 restricts the file so only you can read it. SSH refuses to use key files that are too permissive.
- Confirm the key file was created by running:
ls -la ../ansible/key.pem
You should see the file listed with -rw------- permissions (read/write for owner only).
- Test SSH connectivity to your EC2 instance by running this command (replace the IP with your app_instance_public_ip output value):
ssh -i ../ansible/key.pem -o StrictHostKeyChecking=no ec2-user@[[EC2_IP="your EC2 public IP from terraform output"]]
You should see the Amazon Linux 2023 welcome banner and an ec2-user@ shell prompt. This confirms your EC2 instance is running and accessible.
- Exit the SSH session by typing:
exit
Connection timed out or refused?
If SSH times out, the EC2 instance may still be initializing. Wait 1-2 minutes and try again.
If you see "Permission denied (publickey)", make sure you are using ec2-user as the username (not root or ubuntu) and that key.pem has 600 permissions.
Why can't I SSH into my EC2 instance?
Why extract the key now?
In the next step, you will write an Ansible playbook that uses this key to connect to the EC2 instance and install software. The key file at ansible/key.pem is exactly where the Ansible inventory will point.
In the CI/CD pipeline later, this same extraction happens automatically as a workflow step.
Your entire three-tier infrastructure is now live on AWS. Next up, you will write the Ansible playbook that turns that bare EC2 instance into a fully configured application server.
Write the Ansible Playbook and App Code
Your infrastructure is provisioned, but the EC2 instance is still a blank server. It has no web server, no application, and no connection to the database.
This is where Ansible comes in. Ansible connects to your EC2 instance over SSH and configures it into a fully running application server. You'll write a playbook that installs Nginx, deploys a Node.js app, and wires everything to the RDS database.
In this step, get ready to:
- Create an inventory generation script that converts Terraform outputs into Ansible-readable format.
- Write an Ansible playbook that installs and configures the full application stack on EC2.
- Build the Node.js Express app and test the full deployment manually.
Create the inventory generation script
Ansible needs an inventory file that tells it which servers to connect to and how. Instead of hardcoding IP addresses, you'll write a Python script that reads Terraform's JSON outputs and generates the inventory dynamically.
Why a script instead of a static file?
Every time Terraform provisions infrastructure, resource details like IP addresses and endpoints change. A dynamic inventory script reads the latest Terraform outputs and generates a fresh inventory. This is the industry-standard pattern for connecting Terraform and Ansible in CI/CD pipelines.
- Create the inventory generation script by adding the following code to scripts/generate_inventory.py:
#!/usr/bin/env python3
"""Generate Ansible inventory from Terraform outputs JSON."""
import json
import sys
def main():
if len(sys.argv) > 1:
with open(sys.argv[1], 'r') as f:
outputs = json.load(f)
else:
outputs = json.load(sys.stdin)
app_ip = outputs["app_instance_public_ip"]["value"]
rds_endpoint = outputs["rds_endpoint"]["value"]
inventory = f"""[app]
{app_ip} ansible_user=ec2-user ansible_ssh_private_key_file=../ansible/key.pem ansible_ssh_common_args='-o StrictHostKeyChecking=no'
[app:vars]
rds_endpoint={rds_endpoint}
db_name=appdb
db_username=admin
db_password=ChangeMe123!
"""
print(inventory)
if __name__ == "__main__":
main()
What does this script do?
- It reads Terraform outputs JSON from either a file argument or stdin.
- It extracts the EC2 public IP and RDS endpoint from the outputs.
- It prints an Ansible inventory with the [app] host group (SSH config) and [app:vars] (database connection details passed to the playbook).
- Save scripts/generate_inventory.py.
- Make the script executable by running:
chmod +x scripts/generate_inventory.py
- Test that it runs without errors by piping Terraform outputs into it:
cd terraform && terraform output -json | python3 ../scripts/generate_inventory.py
You should see an inventory printed to your terminal with your EC2 IP address under the [app] group and your RDS endpoint under [app:vars].
Seeing a JSON decode error?
- Make sure you are in the terraform/ directory when running terraform output -json. Terraform needs access to its state file.
- If you get a "No outputs found" error, confirm that terraform apply completed successfully in the previous step.
Help me debug my inventory generation script
Write the Ansible playbook
The playbook defines the complete server configuration. It installs Nginx as a reverse proxy, deploys your Node.js app, and creates a systemd service to keep the app running.
- Create the playbook by adding the following code to ansible/playbook.yml:
---
- name: Configure Three-Tier App Server
hosts: app
become: yes
tasks:
- name: Install Node.js and Nginx
dnf:
name:
- nodejs
- npm
- nginx
state: present
- name: Create app directory
file:
path: /opt/app
state: directory
owner: ec2-user
group: ec2-user
mode: '0755'
- name: Copy application files
copy:
src: "{{ item.src }}"
dest: "/opt/app/{{ item.dest }}"
owner: ec2-user
group: ec2-user
mode: '0644'
loop:
- { src: '../app/index.js', dest: 'index.js' }
- { src: '../app/package.json', dest: 'package.json' }
- name: Install npm dependencies
command: npm install
args:
chdir: /opt/app
become_user: ec2-user
- name: Configure Nginx as reverse proxy
copy:
dest: /etc/nginx/conf.d/app.conf
content: |
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
mode: '0644'
- name: Remove default Nginx config
file:
path: /etc/nginx/conf.d/default.conf
state: absent
- name: Create systemd service for Node.js app
copy:
dest: /etc/systemd/system/nodeapp.service
content: |
[Unit]
Description=Three Tier Node.js App
After=network.target
[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/app
ExecStart=/usr/bin/node index.js
Restart=on-failure
Environment=DB_HOST={{ rds_endpoint }}
Environment=DB_NAME={{ db_name }}
Environment=DB_USER={{ db_username }}
Environment=DB_PASS={{ db_password }}
[Install]
WantedBy=multi-user.target
mode: '0644'
- name: Reload systemd and start services
systemd:
daemon_reload: yes
- name: Start and enable Nginx
systemd:
name: nginx
state: started
enabled: yes
- name: Start and enable Node.js app
systemd:
name: nodeapp
state: started
enabled: yes
What does this playbook do?
This single play configures your EC2 instance from blank server to running application:
- Installs Node.js, npm, and Nginx using dnf (Amazon Linux 2023's package manager).
- Copies your app files to /opt/app and installs npm dependencies.
- Configures Nginx as a reverse proxy that forwards port 80 traffic to your Node.js app on port 3000.
- Creates a systemd service called nodeapp that keeps the app running and passes database connection details as environment variables.
Why Nginx in front of Node.js?
The ALB health check hits port 80. Nginx listens on port 80 and forwards requests to Node.js on port 3000. This is the standard reverse proxy pattern. It lets Nginx handle HTTP concerns (buffering, headers) while Node.js focuses on application logic.
- Save ansible/playbook.yml.
Build the Node.js app and test the deployment
The app is intentionally minimal. It proves the full three-tier connectivity: internet traffic reaches the ALB, the ALB hits EC2, and EC2 connects to RDS.
- Create the package definition by adding the following code to app/package.json:
{
"name": "three-tier-app",
"version": "1.0.0",
"description": "Simple three-tier app for infrastructure demo",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.21.0",
"mysql2": "^3.11.0"
}
}
What are these dependencies?
- express is the web framework that handles HTTP routes.
- mysql2 is the MySQL client that connects to your RDS database.
- Save app/package.json.
- Create the application server by adding the following code to app/index.js:
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
const PORT = 3000;
// Database connection config from environment variables
const dbConfig = {
host: process.env.DB_HOST ? process.env.DB_HOST.split(':')[0] : 'localhost',
port: 3306,
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASS || 'password',
database: process.env.DB_NAME || 'appdb',
};
// Health check endpoint for ALB target group
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
// Main endpoint that proves three-tier connectivity
app.get('/', async (req, res) => {
try {
const connection = await mysql.createConnection(dbConfig);
await connection.execute(
'CREATE TABLE IF NOT EXISTS visits (id INT AUTO_INCREMENT PRIMARY KEY, visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)'
);
await connection.execute('INSERT INTO visits () VALUES ()');
const [rows] = await connection.execute('SELECT COUNT(*) as count FROM visits');
await connection.end();
res.json({
message: 'Hello from the Three-Tier App!',
tier: 'Application (EC2)',
database: 'Connected to RDS MySQL',
total_visits: rows[0].count,
});
} catch (error) {
res.status(500).json({
message: 'Hello from the Three-Tier App!',
tier: 'Application (EC2)',
database: `Connection failed: ${error.message}`,
});
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
What does this app do?
- GET /health returns a 200 status. The ALB target group uses this endpoint to confirm the app is alive.
- GET / connects to the RDS MySQL database, creates a visits table, inserts a row, and returns the total visit count. This proves the full EC2-to-RDS connectivity.
- DB_HOST.split(':')[0] strips the port number from the RDS endpoint (which comes as hostname:3306) since the port is set separately.
- Save app/index.js.
Now test the full deployment by generating an inventory and running the playbook against your live EC2 instance.
- Navigate back to your project root (the three-tier-infra folder).
- Generate the Terraform outputs JSON file by running:
cd terraform && terraform output -json > ../tf_outputs.json && cd ..
- Generate the Ansible inventory from the outputs by running:
python3 scripts/generate_inventory.py tf_outputs.json > ansible/inventory.ini
- Run the Ansible playbook against your EC2 instance:
cd ansible && ansible-playbook -i inventory.ini playbook.yml
What should I see?
Ansible connects to your EC2 instance and runs each task in sequence. You should see green "ok" or yellow "changed" status for each task. The playbook takes 1-2 minutes to complete as it installs packages and starts services.
- Once the playbook finishes, verify the app is running by opening your ALB DNS name in a browser.
- Find your ALB DNS by running:
cd ../terraform && terraform output alb_dns_name
Paste the ALB DNS name into your browser. You should see a JSON response with the message "Hello from the Three-Tier App!" and a visit count. If the database connection fails, the response still shows the app is running but reports the connection error.
Seeing a 502 Bad Gateway or no response?
- The ALB health check takes 30-60 seconds to mark the target as healthy. Wait a minute and try again.
- SSH into your EC2 instance and check if the services are running: sudo systemctl status nodeapp and sudo systemctl status nginx.
- If Nginx is running but the app is not, check the journal: sudo journalctl -u nodeapp -n 20.
Help me troubleshoot my Ansible deployment
✔️ Awesome, I've got everything!
Great. Double check you've saved all your files before moving on.
ⓧ I'd like to double check the full code
Here are the three new files you created in this step.
#!/usr/bin/env python3
"""Generate Ansible inventory from Terraform outputs JSON."""
import json
import sys
def main():
if len(sys.argv) > 1:
with open(sys.argv[1], 'r') as f:
outputs = json.load(f)
else:
outputs = json.load(sys.stdin)
app_ip = outputs["app_instance_public_ip"]["value"]
rds_endpoint = outputs["rds_endpoint"]["value"]
inventory = f"""[app]
{app_ip} ansible_user=ec2-user ansible_ssh_private_key_file=../ansible/key.pem ansible_ssh_common_args='-o StrictHostKeyChecking=no'
[app:vars]
rds_endpoint={rds_endpoint}
db_name=appdb
db_username=admin
db_password=ChangeMe123!
"""
print(inventory)
if __name__ == "__main__":
main()
---
- name: Configure Three-Tier App Server
hosts: app
become: yes
tasks:
- name: Install Node.js and Nginx
dnf:
name:
- nodejs
- npm
- nginx
state: present
- name: Create app directory
file:
path: /opt/app
state: directory
owner: ec2-user
group: ec2-user
mode: '0755'
- name: Copy application files
copy:
src: "{{ item.src }}"
dest: "/opt/app/{{ item.dest }}"
owner: ec2-user
group: ec2-user
mode: '0644'
loop:
- { src: '../app/index.js', dest: 'index.js' }
- { src: '../app/package.json', dest: 'package.json' }
- name: Install npm dependencies
command: npm install
args:
chdir: /opt/app
become_user: ec2-user
- name: Configure Nginx as reverse proxy
copy:
dest: /etc/nginx/conf.d/app.conf
content: |
server {
listen 80;
server_name _;
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
}
mode: '0644'
- name: Remove default Nginx config
file:
path: /etc/nginx/conf.d/default.conf
state: absent
- name: Create systemd service for Node.js app
copy:
dest: /etc/systemd/system/nodeapp.service
content: |
[Unit]
Description=Three Tier Node.js App
After=network.target
[Service]
Type=simple
User=ec2-user
WorkingDirectory=/opt/app
ExecStart=/usr/bin/node index.js
Restart=on-failure
Environment=DB_HOST={{ rds_endpoint }}
Environment=DB_NAME={{ db_name }}
Environment=DB_USER={{ db_username }}
Environment=DB_PASS={{ db_password }}
[Install]
WantedBy=multi-user.target
mode: '0644'
- name: Reload systemd and start services
systemd:
daemon_reload: yes
- name: Start and enable Nginx
systemd:
name: nginx
state: started
enabled: yes
- name: Start and enable Node.js app
systemd:
name: nodeapp
state: started
enabled: yes
{
"name": "three-tier-app",
"version": "1.0.0",
"description": "Simple three-tier app for infrastructure demo",
"main": "index.js",
"scripts": {
"start": "node index.js"
},
"dependencies": {
"express": "^4.21.0",
"mysql2": "^3.11.0"
}
}
const express = require('express');
const mysql = require('mysql2/promise');
const app = express();
const PORT = 3000;
const dbConfig = {
host: process.env.DB_HOST ? process.env.DB_HOST.split(':')[0] : 'localhost',
port: 3306,
user: process.env.DB_USER || 'admin',
password: process.env.DB_PASS || 'password',
database: process.env.DB_NAME || 'appdb',
};
app.get('/health', (req, res) => {
res.status(200).json({ status: 'healthy' });
});
app.get('/', async (req, res) => {
try {
const connection = await mysql.createConnection(dbConfig);
await connection.execute(
'CREATE TABLE IF NOT EXISTS visits (id INT AUTO_INCREMENT PRIMARY KEY, visited_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP)'
);
await connection.execute('INSERT INTO visits () VALUES ()');
const [rows] = await connection.execute('SELECT COUNT(*) as count FROM visits');
await connection.end();
res.json({
message: 'Hello from the Three-Tier App!',
tier: 'Application (EC2)',
database: 'Connected to RDS MySQL',
total_visits: rows[0].count,
});
} catch (error) {
res.status(500).json({
message: 'Hello from the Three-Tier App!',
tier: 'Application (EC2)',
database: `Connection failed: ${error.message}`,
});
}
});
app.listen(PORT, '0.0.0.0', () => {
console.log(`Server running on port ${PORT}`);
});
You now have a fully working three-tier application deployed manually. Next up, you'll automate this entire process with a GitHub Actions pipeline that runs Terraform and Ansible on every push.
Create the GitHub Actions CI/CD Pipeline
You now have all the pieces of a fully automated infrastructure deployment: Terraform provisions resources, Ansible configures servers, and a Python script bridges them with dynamic inventory. The missing piece is the orchestrator that ties everything together on every code push.
In this step, you will create a GitHub Actions workflow that runs Terraform and Ansible in sequence. When you push code to main, the pipeline provisions your infrastructure and configures your servers automatically.
In this step, get ready to:
- Create the GitHub Actions workflow file with the infrastructure provisioning job.
- Add the Ansible configuration job that depends on the infrastructure job.
- Push to main and verify the full pipeline deploys your live application.
Create the workflow file with the infrastructure job
GitHub Actions workflows live in the .github/workflows/ directory. Each YAML file in this folder defines a pipeline that GitHub runs automatically based on triggers you configure.
- Create the .github/workflows/ directory inside your three-tier-infra project folder by running:
mkdir -p .github/workflows
- Create a new file called deploy.yml inside .github/workflows/ and paste the following code:
name: Deploy Three-Tier Infrastructure
on:
push:
branches: [main]
jobs:
infrastructure:
name: Provision Infrastructure
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
- name: Export Terraform Outputs
run: terraform output -json > ../tf_outputs.json
- name: Upload Terraform Outputs
uses: actions/upload-artifact@v4
with:
name: tf-outputs
path: tf_outputs.json
- name: Export SSH Key
run: terraform output -raw private_key > ../ssh_key.pem
- name: Upload SSH Key
uses: actions/upload-artifact@v4
with:
name: ssh-key
path: ssh_key.pem
What does this workflow do?
The on: push: branches: [main] trigger fires the pipeline every time you push to the main branch.
The infrastructure job uses aws-actions/configure-aws-credentials@v4 to load your repository secrets, then runs the full Terraform lifecycle: init, plan, and apply. After provisioning completes, it exports the Terraform outputs and SSH key as artifacts so the next job can use them.
Setting terraform_wrapper: false prevents the setup action from wrapping Terraform output in metadata, which would break JSON parsing in later steps.
Add the configure job
The second job picks up where infrastructure left off. It downloads the artifacts from the first job, generates the Ansible inventory, and runs the playbook to configure your EC2 instance.
- In .github/workflows/deploy.yml, add the following configure job directly below the infrastructure job (at the same indentation level as infrastructure:):
configure:
name: Configure Servers with Ansible
needs: infrastructure
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download Terraform Outputs
uses: actions/download-artifact@v4
with:
name: tf-outputs
path: .
- name: Download SSH Key
uses: actions/download-artifact@v4
with:
name: ssh-key
path: ansible/
- name: Set SSH Key Permissions
run: chmod 600 ansible/key.pem
- name: Wait for EC2 to be ready
run: sleep 60
- name: Generate Ansible Inventory
run: python3 scripts/generate_inventory.py tf_outputs.json > ansible/inventory.ini
- name: Install Ansible
run: pip install ansible
- name: Run Ansible Playbook
working-directory: ansible
run: ansible-playbook -i inventory.ini playbook.yml
env:
ANSIBLE_HOST_KEY_CHECKING: "false"
What does the configure job do?
The needs: infrastructure line ensures this job waits for the first job to succeed before running. This is what creates the sequential relationship between provisioning and configuration.
The sleep 60 step gives the EC2 instance time to fully boot and accept SSH connections. Without this wait, Ansible would try to connect before the instance is ready.
Setting ANSIBLE_HOST_KEY_CHECKING to false prevents Ansible from prompting for SSH host key verification, which would hang the pipeline.
Why two separate jobs instead of one?
Splitting the pipeline into an infrastructure job and a configuration job keeps responsibilities clear. If Ansible fails, you can re-run just the configuration step without re-provisioning your entire AWS stack.
Artifacts bridge the two jobs by passing Terraform outputs and the SSH key between separate runner environments.
- Save .github/workflows/deploy.yml.
✔️ Awesome, I've got everything!
Double check you have saved deploy.yml and that it sits in the .github/workflows/ directory.
ⓧ I'd like to double check the full code
name: Deploy Three-Tier Infrastructure
on:
push:
branches: [main]
jobs:
infrastructure:
name: Provision Infrastructure
runs-on: ubuntu-latest
defaults:
run:
working-directory: terraform
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Configure AWS credentials
uses: aws-actions/configure-aws-credentials@v4
with:
aws-access-key-id: ${{ secrets.AWS_ACCESS_KEY_ID }}
aws-secret-access-key: ${{ secrets.AWS_SECRET_ACCESS_KEY }}
aws-region: us-east-1
- name: Setup Terraform
uses: hashicorp/setup-terraform@v4
with:
terraform_wrapper: false
- name: Terraform Init
run: terraform init
- name: Terraform Plan
run: terraform plan -out=tfplan
- name: Terraform Apply
run: terraform apply -auto-approve tfplan
- name: Export Terraform Outputs
run: terraform output -json > ../tf_outputs.json
- name: Upload Terraform Outputs
uses: actions/upload-artifact@v4
with:
name: tf-outputs
path: tf_outputs.json
- name: Export SSH Key
run: terraform output -raw private_key > ../ssh_key.pem
- name: Upload SSH Key
uses: actions/upload-artifact@v4
with:
name: ssh-key
path: ssh_key.pem
configure:
name: Configure Servers with Ansible
needs: infrastructure
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@v4
- name: Download Terraform Outputs
uses: actions/download-artifact@v4
with:
name: tf-outputs
path: .
- name: Download SSH Key
uses: actions/download-artifact@v4
with:
name: ssh-key
path: ansible/
- name: Set SSH Key Permissions
run: chmod 600 ansible/key.pem
- name: Wait for EC2 to be ready
run: sleep 60
- name: Generate Ansible Inventory
run: python3 scripts/generate_inventory.py tf_outputs.json > ansible/inventory.ini
- name: Install Ansible
run: pip install ansible
- name: Run Ansible Playbook
working-directory: ansible
run: ansible-playbook -i inventory.ini playbook.yml
env:
ANSIBLE_HOST_KEY_CHECKING: "false"
Commit and push to trigger the pipeline
Your workflow triggers on pushes to main. To kick off the full pipeline, you need to commit all your project files and push them to GitHub.
- From the root of your three-tier-infra project folder, stage and commit all files by running:
git add .
git commit -m "Add three-tier infrastructure pipeline"
- Push to the main branch by running:
git push origin main
What happens after pushing?
GitHub detects the push to main and triggers your workflow automatically. The infrastructure job provisions all your AWS resources, then the configure job sets up Nginx and your Node.js app on the EC2 instance.
The full pipeline takes 10-15 minutes because RDS provisioning alone takes 5-10 minutes.
- Open your GitHub repository in your browser.
- Click the Actions tab at the top of the repository.
- Click on the running workflow (Add three-tier infrastructure pipeline) to see the job details.
- Wait for both the Provision Infrastructure and Configure Servers with Ansible jobs to show green checkmarks.
Monitor job logs in real time
Click on either job name to expand the step-by-step logs. This is especially useful for debugging if a step fails. You can watch Terraform creating resources and Ansible configuring your server in real time.
- Once both jobs complete, find the ALB DNS name. Click on the Provision Infrastructure job, expand the Terraform Apply step, and look for the alb_dns_name output near the end.
- Copy the ALB DNS name and paste it into your browser address bar.
You should see a JSON response with the message Hello from the Three-Tier App! along with the database connection status and visit count.
Pipeline failed or ALB not responding?
If the infrastructure job fails at Terraform Apply, check that your AWS credentials in GitHub Secrets are correct and have sufficient permissions (EC2, VPC, RDS, S3, ELB).
If the configure job fails at the Ansible step, the EC2 instance might not be fully ready. The sleep 60 wait is sometimes not enough. Try re-running just the failed job from the Actions tab.
If the ALB returns a 502 or 503 error, give it 1-2 minutes for the health checks to pass. The target group needs at least 2 consecutive healthy responses before routing traffic.
help me debug my GitHub Actions pipeline failure
Your entire three-tier architecture is now fully automated. Push code, and the pipeline builds your AWS infrastructure and deploys your application end-to-end.
Secret mission
Upgrade to GitHub OIDC Authentication
Your pipeline works, but it relies on stored AWS credentials. Long-lived access keys are a security liability that can be leaked, rotated, or compromised. Can you upgrade to keyless OIDC authentication so your pipeline never needs stored secrets again?
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project creates AWS resources that incur ongoing costs, primarily the Application Load Balancer at ~$0.0225/hour (~$16/month).
Cost warning
The ALB charges approximately $16/month even when idle. EC2 and RDS are free-tier eligible for the first 12 months (750 hours/month each), but the ALB is not.
If you are not actively using this infrastructure, run terraform destroy to avoid unexpected charges.
Resources you used:
- S3 bucket for Terraform remote state.
- VPC with 4 subnets, Internet Gateway, and route tables.
- Application Load Balancer with target group and listener.
- EC2 instance (t2.micro) running your Node.js application.
- RDS MySQL instance (db.t3.micro) in private subnets.
- 3 security groups (ALB, App, DB).
- SSH key pair generated by Terraform.
- GitHub repository with Actions workflow.
Keep everything running
No action needed. Choose this if you are still actively building or testing your three-tier infrastructure.
- Your ALB will continue to cost ~$0.0225/hour (~$16/month).
- EC2 and RDS are free-tier eligible for 750 hours/month if your AWS account is under 12 months old.
- The S3 state bucket costs are negligible (fractions of a cent).
- Monitor your AWS billing dashboard to avoid surprises.
Pause - I'll come back to this later
Destroy the AWS infrastructure but keep your code. You can re-deploy anytime with a single terraform apply.
- Navigate to your terraform/ directory and run the destroy command below.
- Keep your GitHub repository intact so you can push to main to redeploy later.
- Keep the S3 state bucket so Terraform can manage state on your next deploy.
Destroy the infrastructure by running:
cd terraform
terraform destroy -auto-approve
This removes all AWS resources (VPC, ALB, EC2, RDS, security groups) but leaves your S3 bucket and GitHub repo untouched.
Delete - I don't want to use this again
Remove all project resources completely and start fresh. This deletes everything from AWS and optionally your GitHub repository.
- Destroy all Terraform-managed AWS resources by running the commands below.
- Delete the S3 state bucket.
- Optionally delete the GitHub repository from your account.
- Delete the local project folder from your computer.
Destroy the infrastructure and remove the state bucket by running:
cd terraform
terraform destroy -auto-approve
aws s3 rb s3://[[TFSTATE_BUCKET="your-unique-bucket-name-tfstate"]] --force
To delete your GitHub repository, navigate to your repository's Settings page, scroll to the bottom, and click Delete this repository.
Finally, delete the local project folder from your Desktop.
That's a Wrap!
That's a Wrap!
Nice work! You just built a production-style DevOps pipeline from scratch.
You've learned how to:
- Built a three-tier VPC architecture on AWS with proper subnet isolation and security group chaining enforcing tiered access between the ALB, EC2, and RDS layers.
- Provisioned compute, networking, and database resources using modular Terraform configuration with S3 remote state, and configured servers automatically with Ansible playbooks driven by dynamic inventory.
- Created a full GitHub Actions CI/CD pipeline that orchestrates Terraform and Ansible end-to-end, deploying your entire stack on every push to main.
- Secret Mission: Upgraded to OIDC-based keyless authentication for secure, credential-free deployments from GitHub Actions to AWS.
Ready to quiz yourself?