Multi-Tier Infrastructure with Terraform
Provision and configure a 3-tier AWS architecture using Terraform and Ansible.
Introduction
30 Second Summary
Every time a company launches a new application, someone has to set up servers, configure networking, and install software. Doing this manually for dozens of servers is slow, error-prone, and impossible to repeat consistently.
In this project, you will build a complete infrastructure automation pipeline that provisions three servers on AWS and configures each one with its designated software stack using a single command.
What You'll Build
You will create a 3-tier architecture (web, app, database) on AWS where Terraform provisions the cloud resources and Ansible automatically configures each server with its designated role.
By the end of this project, you'll have:
- A Terraform configuration that creates a VPC, subnets, security groups, and three EC2 instances on AWS.
- An Ansible playbook with roles that install Nginx, Docker, and MariaDB on the correct servers automatically.
- A dynamic inventory that auto-discovers your cloud instances so Ansible always knows which servers to configure.
- Secret Mission: Add a load balancer and auto scaling to make your infrastructure production-ready.
Are there any prerequisites?
You will need an AWS account with billing enabled. Basic familiarity with Linux terminal commands (like cd and ls) is helpful but not required.
Install Terraform, Ansible, and AWS CLI
To automate infrastructure, you need three tools working together. Terraform provisions cloud resources (creates VPCs, servers, security groups). Ansible configures those resources after they exist (installs software, hardens security). The AWS CLI connects both tools to your AWS account.
In this step, you will install all three tools on your Ubuntu machine and verify they can talk to AWS. Once this is done, you will have a complete automation toolkit ready to build real infrastructure.
In this step, get ready to:
- Install Terraform from the official HashiCorp repository.
- Install Ansible and its AWS dependencies via pip.
- Configure the AWS CLI with your credentials.
Install Terraform via apt
Terraform is an Infrastructure as Code tool that lets you define cloud resources in configuration files and provision them with a single command. You will install it from HashiCorp's official apt repository to ensure you get the latest stable version.
- Check if Terraform is already on your machine by running this command:
terraform --version
✔️ I see version 1.15.0 or higher
Terraform is ready. You should see output like Terraform v1.15.4 (or a higher patch number). Skip ahead to the next substep.
ⓧ I see an older version
You have Terraform installed but it is below the minimum version this project requires (1.15.0). Re-add the HashiCorp repository and upgrade by running these commands.
- Add the HashiCorp GPG key by running:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
- Add the HashiCorp apt repository by running:
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
- Update your package list and upgrade Terraform by running:
sudo apt update && sudo apt install terraform
- Verify the upgrade worked by running:
terraform --version
You should now see Terraform v1.15.0 or higher.
ⓧ Command not found
Terraform is not installed yet. Follow these steps to install it from the official HashiCorp repository.
- Add the HashiCorp GPG key by running:
wget -O - https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
- Add the HashiCorp apt repository by running:
echo "deb [arch=$(dpkg --print-architecture) signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(grep -oP '(?<=UBUNTU_CODENAME=).*' /etc/os-release || lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
- Update your package list and install Terraform by running:
sudo apt update && sudo apt install terraform
- Verify the installation worked by running:
terraform --version
You should now see Terraform v1.15.4 (or a similar patch version above 1.15.0).
Seeing a GPG error or permission denied?
If you see a permissions error on the GPG command, make sure you included sudo before gpg --dearmor. If the file already exists from a previous attempt, delete it first with sudo rm /usr/share/keyrings/hashicorp-archive-keyring.gpg and re-run the command.
help me troubleshoot my Terraform installation on Ubuntu
Install Ansible and AWS dependencies
Ansible handles the configuration side of automation. After Terraform creates your servers, Ansible connects to them via SSH and installs software, applies security settings, and configures services.
You also need the amazon.aws Ansible collection (for dynamic inventory) and boto3 (the Python SDK that talks to AWS APIs).
- Check if Ansible is already on your machine by running:
ansible --version
✔️ I see a version number
Ansible is already installed. Skip ahead to installing the AWS collection below.
ⓧ Command not found
Ansible is not installed yet. Install it using pip.
- Make sure pip is available by running:
sudo apt update && sudo apt install python3-pip -y
- Install Ansible by running:
python3 -m pip install --user ansible
- Verify Ansible installed correctly by running:
ansible --version
You should see version information including the config file path and python version.
Still seeing command not found after pip install?
The --user flag installs to ~/.local/bin, which may not be in your PATH. Add it by running export PATH="$HOME/.local/bin:$PATH" and then try ansible --version again.
help me fix my Ansible PATH issue on Ubuntu
- Install the amazon.aws Ansible collection by running:
ansible-galaxy collection install amazon.aws
What is an Ansible collection?
Collections are packaged bundles of Ansible plugins, modules, and roles. The amazon.aws collection provides the aws_ec2 dynamic inventory plugin you will use later to auto-discover your EC2 instances.
- Install boto3 (the Python library that lets Ansible talk to AWS) by running:
pip install boto3
- Confirm both dependencies are in place by running:
ansible-galaxy collection list | grep amazon.aws
You should see amazon.aws listed with a version number.
Collection not showing up?
Make sure you ran the ansible-galaxy collection install command without errors. If you see a network timeout, check your internet connection and retry.
help me troubleshoot my ansible-galaxy collection install
Configure AWS CLI
The AWS CLI is the command-line interface that authenticates your tools with your AWS account. Both Terraform and Ansible read AWS credentials from the same configuration file that the CLI creates.
- Check if the AWS CLI is already installed by running:
aws --version
✔️ I see a version number
The AWS CLI is installed. Skip ahead to configuring your credentials below.
ⓧ Command not found
The AWS CLI is not installed yet. Install it with apt.
- Install the AWS CLI by running:
sudo apt install awscli -y
- Verify it installed by running:
aws --version
You should see a version string starting with aws-cli.
Now you need to give the CLI your AWS credentials so it can authenticate on your behalf.
- Start the configuration wizard by running:
aws configure
What will aws configure ask me?
The wizard asks four questions in sequence. Have your AWS Access Key ID and Secret Access Key ready from the IAM console.
- AWS Access Key ID: paste your access key.
- AWS Secret Access Key: paste your secret key.
- Default region name: enter us-east-1.
- Default output format: enter json.
- Answer each prompt with the values above, pressing **Enter** after each one.
- Verify your credentials work by running:
aws sts get-caller-identity
You should see a JSON response containing your UserId, Account number, and Arn. This confirms Terraform and Ansible can reach your AWS account.
Seeing InvalidClientTokenId or SignatureDoesNotMatch?
Double-check that you copied the full Access Key ID and Secret Access Key without extra spaces. Run aws configure again to re-enter them.
Also confirm the IAM user has programmatic access enabled. If you just created the key, wait 30 seconds for it to propagate and retry.
help me fix my AWS CLI credentials
Your automation toolkit is locked and loaded. Next up, you will create the Terraform project structure and define the variables your infrastructure needs.
Create the Terraform Project Structure
With your tools installed, you're ready to build the Terraform project that will define your entire infrastructure. Terraform uses declarative configuration files to describe the resources you want to create.
In this step, you'll create a clean project structure with separate files for providers, variables, and outputs. This separation keeps your configuration organized as it grows.
In this step, get ready to:
- Set up the project directory on your Desktop.
- Configure the AWS provider in main.tf.
- Define input variables in variables.tf.
Set up the project directory
Every Terraform project lives in its own directory. Each file has a specific role: main.tf holds resource definitions, variables.tf holds inputs, and outputs.tf holds values Terraform exposes after running.
- Move to your Desktop by running this command:
cd ~/Desktop
- Create your project folder and move into it by running these commands:
mkdir infra-automation
cd infra-automation
- Create the project files and the Ansible directory by running these commands:
touch main.tf variables.tf outputs.tf terraform.tfvars
mkdir ansible
- Confirm the files exist by running:
ls
You should see main.tf, variables.tf, outputs.tf, terraform.tfvars, and the ansible directory listed in the output.
Don't see all the files?
Make sure you ran the touch and mkdir commands from inside the infra-automation folder. If your terminal shows a different path, run cd ~/Desktop/infra-automation and try the touch command again.
Still stuck? Help me create my Terraform project directory structure on Ubuntu
- Open the project folder in VS Code by running:
code .
Configure the AWS provider
The provider block tells Terraform which cloud platform to talk to and how to authenticate. You also pin the provider version so your configuration stays reproducible across machines.
- In VS Code, open main.tf and paste the following code:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
What does this code do?
The required_providers block pins the AWS provider to version ~> 6.0. This means Terraform will use any 6.x release but won't jump to 7.0, protecting you from breaking changes.
The required_version line ensures anyone running this project has Terraform 1.15.0 or newer installed.
The provider block sets the AWS region using a variable (var.aws_region) that you'll define next. This makes the region easy to change without editing provider configuration directly.
- Save main.tf.
- You won't see a change yet. The next substep defines the variables this provider references.
Define input variables
Variables let you parameterize your infrastructure. Instead of hardcoding values like region or instance size, you define them once and reuse them across your configuration.
- Open variables.tf and paste the following code:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public web subnet"
type = string
default = "10.0.1.0/24"
}
variable "private_app_subnet_cidr" {
description = "CIDR block for the private app subnet"
type = string
default = "10.0.2.0/24"
}
variable "private_db_subnet_cidr" {
description = "CIDR block for the private db subnet"
type = string
default = "10.0.3.0/24"
}
variable "instance_type" {
description = "EC2 instance type for all servers"
type = string
default = "t3.micro"
}
variable "key_name" {
description = "Name of the SSH key pair"
type = string
default = "deployer-key"
}
What does this code do?
Each variable block declares one input your Terraform project accepts. The default value is used automatically unless you override it in terraform.tfvars or at the command line.
The network variables (vpc_cidr, public_subnet_cidr, private_app_subnet_cidr, private_db_subnet_cidr) define the IP address ranges for your VPC and each tier's subnet.
The instance_type defaults to t3.micro, which is the smallest general-purpose instance and keeps costs low. The key_name variable stores the name of the SSH key pair you'll create in a later step.
- Save variables.tf.
- Verify your Terraform files are valid by running this in the terminal:
terraform fmt
If the command returns with no output, your files are properly formatted. If it prints a filename, Terraform auto-fixed the formatting in that file.
Seeing an error about unsupported arguments?
Double check that every variable block in variables.tf has matching opening and closing braces. A missing } at the end of a block is the most common cause.
Also confirm that main.tf uses var.aws_region (not a hardcoded string) in the provider block.
Help me fix a terraform fmt error in my project
✔️ Awesome, I've got everything!
Great. Double check you've saved both main.tf and variables.tf.
ⓧ I'd like to double check the full code
Here is the complete main.tf:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
Here is the complete variables.tf:
variable "aws_region" {
description = "AWS region for all resources"
type = string
default = "us-east-1"
}
variable "vpc_cidr" {
description = "CIDR block for the VPC"
type = string
default = "10.0.0.0/16"
}
variable "public_subnet_cidr" {
description = "CIDR block for the public web subnet"
type = string
default = "10.0.1.0/24"
}
variable "private_app_subnet_cidr" {
description = "CIDR block for the private app subnet"
type = string
default = "10.0.2.0/24"
}
variable "private_db_subnet_cidr" {
description = "CIDR block for the private db subnet"
type = string
default = "10.0.3.0/24"
}
variable "instance_type" {
description = "EC2 instance type for all servers"
type = string
default = "t3.micro"
}
variable "key_name" {
description = "Name of the SSH key pair"
type = string
default = "deployer-key"
}
The outputs.tf file should be empty for now. You'll add outputs in a later step.
Your project structure is ready and your provider is configured. Next up, you'll use these files to provision the network layer with a VPC, subnets, and security groups.
Provision the Network Layer with Terraform
Your project structure is ready with the provider configured and all variables defined. Now it is time to build the network foundation that your three servers will live on.
A multi-tier architecture separates servers into layers so that each tier only communicates with its immediate neighbour. In this step, you will define a VPC with isolated subnets, routing rules, and security groups that enforce this layered access pattern.
In this step, get ready to:
- Create a VPC with one public and two private subnets.
- Set up routing with an Internet Gateway and NAT Gateway.
- Define layered security groups for web, app, and database tiers.
Create the VPC and subnets
Every AWS network starts with a VPC, which is your own isolated section of the cloud. Inside it, subnets divide the address space into segments where different tiers of servers will live.
- In main.tf, add the following code below your provider "aws" block to define the VPC and three subnets:
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "multitier-vpc"
Project = "infra-automation"
}
}
# --- Subnets ---
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "public-web-subnet"
Tier = "web"
}
}
resource "aws_subnet" "private_app" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_app_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-app-subnet"
Tier = "app"
}
}
resource "aws_subnet" "private_db" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_db_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-db-subnet"
Tier = "db"
}
}
What does this code do?
The aws_vpc resource creates a virtual network with a /16 CIDR block (65,536 addresses). DNS support is enabled so instances can resolve each other by hostname.
- The public subnet gets map_public_ip_on_launch = true, which gives instances in this subnet a public IP automatically.
- The private_app and private_db subnets have no public IP mapping, so they are hidden from the internet.
- All three subnets reference the variables you defined in variables.tf, keeping your config flexible.
- Save main.tf.
Why three separate subnets?
Each tier lives in its own subnet so you can apply different routing and access rules to each one. The web tier needs internet access. The app and database tiers do not.
This isolation is the foundation of the layered security you will build next.
Set up routing
Subnets on their own cannot reach the internet. You need gateways and route tables to control how traffic flows in and out of each subnet.
- In main.tf, add the following code below your subnet definitions to set up the gateways and route tables:
# --- Internet Gateway ---
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "multitier-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "nat-eip"
}
}
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "multitier-nat"
}
depends_on = [aws_internet_gateway.igw]
}
# --- Route Tables ---
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "private-rt"
}
}
resource "aws_route_table_association" "private_app" {
subnet_id = aws_subnet.private_app.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_db" {
subnet_id = aws_subnet.private_db.id
route_table_id = aws_route_table.private.id
}
What does this code do?
This code creates two traffic paths through your network:
- The Internet Gateway gives the public subnet direct internet access. The public route table sends all outbound traffic (0.0.0.0/0) through it.
- The NAT Gateway sits in the public subnet and lets private instances reach the internet for updates, without exposing them to inbound traffic. The private route table routes outbound traffic through it.
- Route table associations link each subnet to its correct route table. The public subnet uses the public route table. Both private subnets share the private route table.
- Save main.tf.
Why a NAT Gateway?
Your app and database servers need to download software packages during configuration, but they should never be reachable directly from the internet. A NAT Gateway solves this by letting private instances initiate outbound connections while blocking all inbound ones.
NAT Gateway costs
NAT Gateways cost approximately $0.045 per hour while running. This is the biggest cost driver in this project. You will destroy all resources at the end to stop charges.
Define security groups
Security groups act as virtual firewalls that control which traffic can reach each tier. You will create three groups with rules that enforce your layered architecture.
- In main.tf, add the web tier security group below your route table associations:
# --- Security Groups ---
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "web-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "web_http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_https" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "web_all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
The web security group is the only tier open to the public internet. It allows inbound HTTP (port 80), HTTPS (port 443), and SSH (port 22) from any IP address.
The egress rule with ip_protocol = "-1" allows all outbound traffic so the web server can reach the app tier and download updates.
Why separate ingress rule resources?
Terraform's AWS provider recommends using aws_vpc_security_group_ingress_rule and aws_vpc_security_group_egress_rule resources instead of inline blocks within the security group. This avoids conflicts and is the current best practice.
- Add the app and database tier security groups below the web security group rules:
resource "aws_security_group" "app" {
name = "app-sg"
description = "Security group for app tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "app_ssh_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "db_ssh_from_web" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
What does this code do?
These two security groups enforce the layered access pattern:
- The app security group only accepts traffic from the web security group on port 8080 (application traffic) and port 22 (SSH for management). It references the web SG by ID using referenced_security_group_id instead of a CIDR block.
- The db security group only accepts traffic from the app security group on port 3306 (MariaDB). SSH access comes from the web tier so you can jump through it as a bastion host.
- No tier can bypass the one above it. The database is invisible to the internet, and even the web server cannot connect to it directly.
- Save main.tf.
- Validate your configuration by running this command:
terraform validate
You should see Success! The configuration is valid. in the output.
Seeing a validation error?
Check for missing closing braces in your resource blocks. Each resource needs matching { and } pairs.
Make sure all variable references match the names in variables.tf exactly (e.g. var.vpc_cidr not var.cidr).
If you see a provider error, run terraform init first to download the AWS provider.
Help me debug my Terraform validation error.
✔️ Awesome, I've got everything!
Great. Make sure you have saved main.tf before moving on.
ⓧ I'd like to double check the full code
Here is the complete main.tf file as it should look at the end of this step:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "multitier-vpc"
Project = "infra-automation"
}
}
# --- Subnets ---
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "public-web-subnet"
Tier = "web"
}
}
resource "aws_subnet" "private_app" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_app_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-app-subnet"
Tier = "app"
}
}
resource "aws_subnet" "private_db" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_db_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-db-subnet"
Tier = "db"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "multitier-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "nat-eip"
}
}
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "multitier-nat"
}
depends_on = [aws_internet_gateway.igw]
}
# --- Route Tables ---
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "private-rt"
}
}
resource "aws_route_table_association" "private_app" {
subnet_id = aws_subnet.private_app.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_db" {
subnet_id = aws_subnet.private_db.id
route_table_id = aws_route_table.private.id
}
# --- Security Groups ---
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "web-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "web_http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_https" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "web_all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "app" {
name = "app-sg"
description = "Security group for app tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "app_ssh_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "db_ssh_from_web" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
Your entire network layer is now defined in code. Next up, you will provision the EC2 instances that sit inside these subnets and generate the SSH keys to access them.
Provision EC2 Instances and Generate SSH Keys
Your network layer is in place with a VPC, subnets, and security groups. Now you need actual servers running inside that network.
In this step, you will generate an SSH key pair for secure access, then launch three EC2 instances into your tiers. Each instance gets tagged with its role so Ansible can discover them automatically later.
In this step, get ready to:
- Generate an SSH key pair and register it with AWS.
- Launch three EC2 instances across your public and private subnets.
- Define Terraform outputs and apply the full infrastructure.
Create an SSH key pair
Terraform needs an SSH key pair to provision on each instance. You will generate the key locally, then tell Terraform to register the public half with AWS.
- Generate a new SSH key pair by running this command:
ssh-keygen -t ed25519 -f ~/.ssh/deployer_key -N ""
What does this command do?
- -t ed25519 specifies the Ed25519 algorithm, which produces small but strong keys.
- -f ~/.ssh/deployer_key sets the output path. This creates two files: deployer_key (private) and deployer_key.pub (public).
- -N "" sets an empty passphrase so automation tools can use the key without prompting.
- Confirm the key files were created by running:
ls ~/.ssh/deployer_key*
You should see two files listed: deployer_key and deployer_key.pub.
Not seeing the key files?
If you get a "file exists" error, a previous key at that path already exists. Either delete it with rm ~/.ssh/deployer_key* and rerun the command, or use the existing key.
Make sure you have a ~/.ssh directory. Create it with mkdir -p ~/.ssh if needed.
Still stuck? Help me troubleshoot my SSH key generation.
Now you need Terraform to register the public key with AWS so your instances accept connections from this key.
- In main.tf, scroll to the bottom of the file (after the last security group egress rule) and add the following key pair resource:
# --- Key Pair ---
resource "aws_key_pair" "deployer" {
key_name = var.key_name
public_key = file("~/.ssh/deployer_key.pub")
}
What does this code do?
- aws_key_pair "deployer" registers your public key with AWS under the name defined in var.key_name (which defaults to deployer-key).
- file("~/.ssh/deployer_key.pub") reads your local public key file at apply time and uploads its contents to AWS.
- Every EC2 instance you launch with this key pair will automatically trust connections from your private key.
- Save main.tf.
You will not see a change yet. The next chunk defines the instances that use this key.
Launch three EC2 instances
Each tier needs its own server running Ubuntu. Instead of hardcoding an AMI ID (which changes by region and date), you will use a data source to always fetch the latest Ubuntu 24.04 image from Canonical.
- In main.tf, add the following AMI data source below the key pair resource:
# --- EC2 Instances ---
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
What does this code do?
- data "aws_ami" queries AWS for an existing AMI rather than creating a resource.
- owners = ["099720109477"] limits results to Canonical (the official Ubuntu publisher).
- The name filter narrows to Ubuntu 24.04 (Noble) with gp3 storage. The wildcard * matches any release timestamp, and most_recent = true picks the newest one.
- Save main.tf.
You will not see a change yet. The next chunk uses this AMI to launch your servers.
Now you will define the three EC2 instances. Each instance is placed in its designated subnet, assigned its tier's security group, and tagged with a Role tag that Ansible will use for discovery.
- In main.tf, add the three instance resources below the AMI data source:
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "web-server"
Role = "web"
Project = "infra-automation"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_app.id
vpc_security_group_ids = [aws_security_group.app.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "app-server"
Role = "app"
Project = "infra-automation"
}
}
resource "aws_instance" "db" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_db.id
vpc_security_group_ids = [aws_security_group.db.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "db-server"
Role = "db"
Project = "infra-automation"
}
}
What does this code do?
Each instance follows the same pattern with tier-specific values:
- web goes into the public subnet with the web security group. It gets a public IP automatically (from map_public_ip_on_launch on the subnet).
- app goes into the private app subnet. It can only be reached from the web tier on port 8080.
- db goes into the private db subnet. It can only be reached from the app tier on port 3306.
- The Role tag on each instance is what Ansible's dynamic inventory will use to group servers by tier.
- Save main.tf.
You will not see a change yet. The next substep defines outputs and runs terraform apply to create everything.
Define outputs and apply the infrastructure
Terraform outputs let you extract key values (like IP addresses) after apply. You will need the web server's public IP and the VPC ID for later steps.
- Open outputs.tf and add the following output definitions:
output "web_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "app_private_ip" {
description = "Private IP of the app server"
value = aws_instance.app.private_ip
}
output "db_private_ip" {
description = "Private IP of the db server"
value = aws_instance.db.private_ip
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
What does this code do?
- web_public_ip gives you the address to SSH into the web server and to access it from a browser.
- app_private_ip and db_private_ip are internal addresses. You will use these for the Nginx reverse proxy and database connections.
- vpc_id identifies your VPC. Ansible's dynamic inventory will filter instances by this VPC.
- Save outputs.tf.
Now it is time to provision everything. You will initialize Terraform to download the AWS provider, preview the plan, then apply it.
- Initialize Terraform by running:
terraform init
- Preview the infrastructure changes by running:
terraform plan
You should see a summary showing resources to add (including your VPC, subnets, security groups, key pair, and three instances). No resources should be changed or destroyed.
- Apply the infrastructure by running:
terraform apply
- Terraform will ask you to confirm. Type yes and press Enter.
What should I see?
Terraform will take 1-3 minutes to create all resources. When it finishes, you will see Apply complete! followed by your outputs: the web server's public IP, private IPs for the app and db servers, and the VPC ID.
Seeing an error during apply?
- "Error: No matching AMI found" means the AMI filter did not return results. Double check the filter value matches exactly: ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*.
- "Error: InvalidKeyPair.Duplicate" means a key pair with the name deployer-key already exists in your AWS account. Delete it from the EC2 console under Key Pairs and retry.
- "Error: error reading file" for the public key means ~/.ssh/deployer_key.pub does not exist. Go back and rerun the ssh-keygen command from earlier.
Help me debug my terraform apply error.
- Confirm the outputs are displayed by running:
terraform output
You should see four values printed: web_public_ip with a public IP address, app_private_ip and db_private_ip with 10.0.x.x addresses, and vpc_id with a vpc- prefixed ID.
Why tag instances with a Role?
Tags are metadata attached to AWS resources. In later steps, Ansible's dynamic inventory plugin will query AWS and group instances by their Role tag value.
This means you never maintain a static list of server IPs. When instances scale up or get replaced, Ansible automatically discovers the new ones based on their tags.
✔️ Awesome, I've got everything!
Great. Double check you have saved both main.tf and outputs.tf.
ⓧ I'd like to double check the full code
Here is the full main.tf at this point:
terraform {
required_providers {
aws = {
source = "hashicorp/aws"
version = "~> 6.0"
}
}
required_version = ">= 1.15.0"
}
provider "aws" {
region = var.aws_region
}
# --- VPC ---
resource "aws_vpc" "main" {
cidr_block = var.vpc_cidr
enable_dns_support = true
enable_dns_hostnames = true
tags = {
Name = "multitier-vpc"
Project = "infra-automation"
}
}
# --- Subnets ---
resource "aws_subnet" "public" {
vpc_id = aws_vpc.main.id
cidr_block = var.public_subnet_cidr
availability_zone = "${var.aws_region}a"
map_public_ip_on_launch = true
tags = {
Name = "public-web-subnet"
Tier = "web"
}
}
resource "aws_subnet" "private_app" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_app_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-app-subnet"
Tier = "app"
}
}
resource "aws_subnet" "private_db" {
vpc_id = aws_vpc.main.id
cidr_block = var.private_db_subnet_cidr
availability_zone = "${var.aws_region}a"
tags = {
Name = "private-db-subnet"
Tier = "db"
}
}
# --- Internet Gateway ---
resource "aws_internet_gateway" "igw" {
vpc_id = aws_vpc.main.id
tags = {
Name = "multitier-igw"
}
}
# --- NAT Gateway ---
resource "aws_eip" "nat" {
domain = "vpc"
tags = {
Name = "nat-eip"
}
}
resource "aws_nat_gateway" "nat" {
allocation_id = aws_eip.nat.id
subnet_id = aws_subnet.public.id
tags = {
Name = "multitier-nat"
}
depends_on = [aws_internet_gateway.igw]
}
# --- Route Tables ---
resource "aws_route_table" "public" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
gateway_id = aws_internet_gateway.igw.id
}
tags = {
Name = "public-rt"
}
}
resource "aws_route_table_association" "public" {
subnet_id = aws_subnet.public.id
route_table_id = aws_route_table.public.id
}
resource "aws_route_table" "private" {
vpc_id = aws_vpc.main.id
route {
cidr_block = "0.0.0.0/0"
nat_gateway_id = aws_nat_gateway.nat.id
}
tags = {
Name = "private-rt"
}
}
resource "aws_route_table_association" "private_app" {
subnet_id = aws_subnet.private_app.id
route_table_id = aws_route_table.private.id
}
resource "aws_route_table_association" "private_db" {
subnet_id = aws_subnet.private_db.id
route_table_id = aws_route_table.private.id
}
# --- Security Groups ---
resource "aws_security_group" "web" {
name = "web-sg"
description = "Security group for web tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "web-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "web_http" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 80
to_port = 80
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_https" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 443
to_port = 443
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "web_ssh" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "web_all" {
security_group_id = aws_security_group.web.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "app" {
name = "app-sg"
description = "Security group for app tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "app-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "app_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 8080
to_port = 8080
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "app_ssh_from_web" {
security_group_id = aws_security_group.app.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "app_all" {
security_group_id = aws_security_group.app.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
resource "aws_security_group" "db" {
name = "db-sg"
description = "Security group for database tier"
vpc_id = aws_vpc.main.id
tags = {
Name = "db-sg"
}
}
resource "aws_vpc_security_group_ingress_rule" "db_from_app" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.app.id
from_port = 3306
to_port = 3306
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "db_ssh_from_web" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.web.id
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "db_all" {
security_group_id = aws_security_group.db.id
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}
# --- Key Pair ---
resource "aws_key_pair" "deployer" {
key_name = var.key_name
public_key = file("~/.ssh/deployer_key.pub")
}
# --- EC2 Instances ---
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"] # Canonical
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
filter {
name = "virtualization-type"
values = ["hvm"]
}
}
resource "aws_instance" "web" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.public.id
vpc_security_group_ids = [aws_security_group.web.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "web-server"
Role = "web"
Project = "infra-automation"
}
}
resource "aws_instance" "app" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_app.id
vpc_security_group_ids = [aws_security_group.app.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "app-server"
Role = "app"
Project = "infra-automation"
}
}
resource "aws_instance" "db" {
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
subnet_id = aws_subnet.private_db.id
vpc_security_group_ids = [aws_security_group.db.id]
key_name = aws_key_pair.deployer.key_name
tags = {
Name = "db-server"
Role = "db"
Project = "infra-automation"
}
}
And here is outputs.tf:
output "web_public_ip" {
description = "Public IP of the web server"
value = aws_instance.web.public_ip
}
output "app_private_ip" {
description = "Private IP of the app server"
value = aws_instance.app.private_ip
}
output "db_private_ip" {
description = "Private IP of the db server"
value = aws_instance.db.private_ip
}
output "vpc_id" {
description = "ID of the VPC"
value = aws_vpc.main.id
}
Your three-tier infrastructure is live. You have a web server with a public IP, an app server and database server tucked away in private subnets, and SSH access ready to go. Next up, you will configure Ansible to automatically discover these instances using dynamic inventory.
Configure Ansible Dynamic Inventory
Your three EC2 instances are running, but Ansible has no idea they exist yet.
In a traditional setup, you would manually list each server's IP address in a static inventory file. Every time your infrastructure changes, that file goes stale. Dynamic inventory solves this by querying AWS directly, so Ansible always gets a live list of running instances.
In this step, get ready to:
- Create a dynamic inventory file that auto-discovers your EC2 instances.
- Configure Ansible settings for SSH connectivity.
- Verify that Ansible groups your servers by their role tags.
Create the dynamic inventory file
The amazon.aws.aws_ec2 plugin lets Ansible query the EC2 API for running instances. Instead of hardcoding IP addresses, the plugin filters instances by tags and groups them automatically.
- Create the inventory directory inside your ansible/ folder by running this command:
mkdir -p ansible/inventory
- Confirm the directory was created by running:
ls ansible/
You should see inventory listed in the output.
- Create the dynamic inventory file by running:
# ansible/inventory/aws_ec2.yml
# Dynamic inventory plugin that queries AWS for running EC2 instances
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Project:
- infra-automation
instance-state-name:
- running
keyed_groups:
- key: tags.Role
prefix: tag_Role
compose:
ansible_host: public_ip_address | default(private_ip_address)
hostnames:
- tag:Name
What does this inventory file do?
- plugin: amazon.aws.aws_ec2 tells Ansible to use the AWS EC2 dynamic inventory plugin instead of a static file.
- filters narrows results to only instances tagged with Project: infra-automation that are currently running.
- keyed_groups automatically creates groups like tag_Role_web, tag_Role_app, and tag_Role_db based on each instance's Role tag.
- compose sets ansible_host to the public IP if available, falling back to the private IP for instances in private subnets.
- hostnames uses the instance's Name tag as its display name in Ansible output.
Why does the filename matter?
The amazon.aws.aws_ec2 plugin requires inventory files to end with aws_ec2.yml or aws_ec2.yaml. If the filename doesn't match this pattern, Ansible won't recognize it as a dynamic inventory source.
- Save ansible/inventory/aws_ec2.yml.
File not saving?
Make sure you created the ansible/inventory/ directory first. If you skipped the mkdir -p command, the directory doesn't exist and the file has nowhere to go.
help me create my dynamic inventory file
Create the Ansible configuration file
Ansible needs to know which SSH key and user to connect with. An ansible.cfg file in the project directory sets these defaults so you don't have to specify them on every command.
- Create the Ansible configuration file by adding the following content to ansible/ansible.cfg:
# ansible/ansible.cfg
# Project-level Ansible configuration
[defaults]
host_key_checking = False
remote_user = ubuntu
private_key_file = ~/.ssh/deployer_key
[inventory]
enable_plugins = amazon.aws.aws_ec2, auto
What do these settings do?
- host_key_checking = False skips the SSH fingerprint prompt when connecting to new servers for the first time.
- remote_user = ubuntu tells Ansible to connect as the ubuntu user, which is the default on Ubuntu EC2 instances.
- private_key_file points to the SSH key you generated and registered with AWS in the previous step.
- enable_plugins activates the amazon.aws.aws_ec2 inventory plugin so Ansible recognizes your dynamic inventory file.
- Save ansible/ansible.cfg.
- Confirm both files exist by running:
ls ansible/ ansible/inventory/
You should see ansible.cfg in the ansible/ listing and aws_ec2.yml in the ansible/inventory/ listing.
Missing a file?
Double-check that you saved each file in the correct location. The config goes in ansible/ansible.cfg and the inventory goes in ansible/inventory/aws_ec2.yml.
help me find where my Ansible files should be saved
Test the dynamic inventory
Now you can verify that Ansible successfully discovers your three EC2 instances and groups them by role.
- Test the inventory by running this command from your project root:
cd ansible && ansible-inventory -i inventory/aws_ec2.yml --graph
What should I see?
The output displays an ASCII tree showing your instances grouped by their Role tag. You should see three groups: tag_Role_web, tag_Role_app, and tag_Role_db.
Each group contains one host named after its Name tag (web-server, app-server, db-server). The @ prefix indicates a group name, while hosts appear without a prefix.
Seeing an empty inventory or errors?
- Make sure your AWS credentials are configured. Run aws sts get-caller-identity to confirm.
- Verify your EC2 instances are still running. Run aws ec2 describe-instances --filters "Name=tag:Project,Values=infra-automation" --query "Reservations[].Instances[].State.Name" to check.
- Confirm the inventory filename ends with aws_ec2.yml. A different suffix causes the plugin to silently fail.
- Check that boto3 is installed. Run pip show boto3 to verify.
help me debug my Ansible dynamic inventory
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is the complete content for both files:
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:Project:
- infra-automation
instance-state-name:
- running
keyed_groups:
- key: tags.Role
prefix: tag_Role
compose:
ansible_host: public_ip_address | default(private_ip_address)
hostnames:
- tag:Name
[defaults]
host_key_checking = False
remote_user = ubuntu
private_key_file = ~/.ssh/deployer_key
[inventory]
enable_plugins = amazon.aws.aws_ec2, auto
Your dynamic inventory is working. Ansible can now auto-discover every instance in your infrastructure without you maintaining a single IP address. Next up, you'll build Ansible roles that configure each tier with its designated software stack.
Build Ansible Roles for Each Tier
Your infrastructure is provisioned and your dynamic inventory can discover all three servers. The next challenge is configuring each server with the right software stack.
Each tier needs different software. The web tier needs Nginx as a reverse proxy. The app tier needs Docker to run containers. The database tier needs MariaDB for persistent storage. Ansible roles let you package each tier's configuration into a reusable, self-contained unit.
In this step, get ready to:
- Create the web role to install and configure Nginx as a reverse proxy.
- Create the app role to install Docker and run a sample container.
- Create the db role to install and secure MariaDB.
Create the web role (Nginx)
Ansible roles follow a standard directory structure with folders for tasks, handlers, templates, and defaults. The ansible-galaxy tool scaffolds this structure for you automatically.
- Create the roles directory and scaffold the web role by running these commands:
mkdir -p ansible/roles
cd ansible/roles
ansible-galaxy role init web
- Move back to the project root by running:
cd ../..
What does ansible-galaxy role init do?
This command creates a standardized directory structure inside ansible/roles/web/ with folders for tasks, handlers, templates, defaults, and more. You only need to fill in the files relevant to your role.
- Open ansible/roles/web/tasks/main.yml and replace its contents with the following code:
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Start and enable Nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Allow HTTP through UFW
community.general.ufw:
rule: allow
port: "80"
proto: tcp
- name: Allow HTTPS through UFW
community.general.ufw:
rule: allow
port: "443"
proto: tcp
- name: Configure Nginx reverse proxy
ansible.builtin.template:
src: nginx_reverse_proxy.conf.j2
dest: /etc/nginx/sites-available/app
mode: "0644"
notify: Reload Nginx
- name: Enable the site configuration
ansible.builtin.file:
src: /etc/nginx/sites-available/app
dest: /etc/nginx/sites-enabled/app
state: link
notify: Reload Nginx
What does this code do?
This playbook installs Nginx, starts the service, and opens firewall ports 80 and 443. It then deploys a reverse proxy configuration from a Jinja2 template and symlinks it into the sites-enabled directory.
The notify: Reload Nginx lines trigger a handler to reload Nginx whenever the configuration changes. This ensures Nginx picks up new settings without a full restart.
- Open ansible/roles/web/handlers/main.yml and replace its contents with the handler:
---
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
What is a handler?
Handlers are tasks that only run when triggered by a notify directive. If multiple tasks notify the same handler, it runs once at the end of the play. This prevents unnecessary service restarts.
- Create the templates directory and add the reverse proxy template by running:
mkdir -p ansible/roles/web/templates
- Create the file ansible/roles/web/templates/nginx_reverse_proxy.conf.j2 with this content:
server {
listen 80;
server_name _;
location / {
proxy_pass http://{{ hostvars[groups['tag_Role_app'][0]]['private_ip_address'] }}:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
How does the template know the app server IP?
The Jinja2 expression hostvars[groups['tag_Role_app'][0]]['private_ip_address'] dynamically looks up the private IP of the first host in the tag_Role_app inventory group. This means the template always points to the correct app server, even if IPs change after re-provisioning.
- Save all three files (tasks/main.yml, handlers/main.yml, nginx_reverse_proxy.conf.j2).
- Verify the role directory looks correct by running:
ls ansible/roles/web/tasks/ ansible/roles/web/handlers/ ansible/roles/web/templates/
You should see main.yml in both tasks and handlers, and nginx_reverse_proxy.conf.j2 in templates.
Missing templates directory?
If the templates folder does not appear, make sure you ran the mkdir -p ansible/roles/web/templates command. The ansible-galaxy role init scaffold creates a templates directory, but confirm it exists before adding the file.
Help me fix my web role directory structure
Create the app role (Docker)
The app tier runs containerized applications. This role installs Docker from the official repository and starts a sample container that listens on port 8080.
- Scaffold the app role by running:
cd ansible/roles
ansible-galaxy role init app
cd ../..
- Open ansible/roles/app/tasks/main.yml and replace its contents with the following:
---
- name: Install Docker prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
state: present
- name: Add Docker GPG key
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
state: present
update_cache: true
- name: Start and enable Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Add ubuntu user to docker group
ansible.builtin.user:
name: ubuntu
groups: docker
append: true
- name: Allow port 8080 through UFW
community.general.ufw:
rule: allow
port: "8080"
proto: tcp
- name: Run a sample container
community.docker.docker_container:
name: sample-app
image: nginx:alpine
state: started
ports:
- "8080:80"
restart_policy: unless-stopped
What does this code do?
The first section installs Docker's prerequisites, adds the official Docker GPG key, and registers the Docker apt repository. This ensures you get the latest Docker Engine directly from Docker, not the older version bundled with Ubuntu.
The second section installs the Docker packages, starts the service, and adds the ubuntu user to the docker group so it can run containers without sudo. Finally, it opens port 8080 and launches an nginx:alpine container mapped to that port.
- Save ansible/roles/app/tasks/main.yml.
You won't see a change yet. The next substep completes the database role, and you will verify all roles together at the end.
Seeing a YAML syntax error?
YAML is sensitive to indentation. Make sure every list item under name: uses exactly two spaces of indentation. Mixing tabs and spaces will cause parse failures.
Help me fix my YAML syntax error
Create the db role (MariaDB)
The database tier needs MariaDB installed, secured, and configured to only accept connections from the app subnet. This role also creates the application database and user that the app tier will connect with.
- Scaffold the db role by running:
cd ansible/roles
ansible-galaxy role init db
cd ../..
- Open ansible/roles/db/tasks/main.yml and replace its contents with:
---
- name: Install MariaDB server
ansible.builtin.apt:
name:
- mariadb-server
- mariadb-client
- python3-pymysql
state: present
- name: Start and enable MariaDB
ansible.builtin.service:
name: mariadb
state: started
enabled: true
- name: Secure MariaDB - set root password
community.mysql.mysql_user:
name: root
password: "{{ db_root_password }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Secure MariaDB - remove anonymous users
community.mysql.mysql_user:
name: ""
host_all: true
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Secure MariaDB - remove test database
community.mysql.mysql_db:
name: test
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Create application database
community.mysql.mysql_db:
name: appdb
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Create application database user
community.mysql.mysql_user:
name: appuser
password: "{{ db_app_password }}"
priv: "appdb.*:ALL"
host: "10.0.2.%"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Bind MariaDB to private IP only
ansible.builtin.lineinfile:
path: /etc/mysql/mariadb.conf.d/50-server.cnf
regexp: "^bind-address"
line: "bind-address = {{ ansible_default_ipv4.address }}"
notify: Restart MariaDB
- name: Allow MariaDB port through UFW
community.general.ufw:
rule: allow
port: "3306"
proto: tcp
What does this code do?
The first section installs MariaDB server, client tools, and the Python MySQL library (required for Ansible's MySQL modules to work). It then starts and enables the service.
The security section sets a root password, removes anonymous users, and drops the test database. These are the same steps you would run manually with mysql_secure_installation, but fully automated.
The final section creates the appdb database and an appuser that can only connect from the 10.0.2.% subnet (the app tier). Binding MariaDB to the private IP prevents connections from the public internet.
- Open ansible/roles/db/handlers/main.yml and replace its contents with:
---
- name: Restart MariaDB
ansible.builtin.service:
name: mariadb
state: restarted
- Open ansible/roles/db/defaults/main.yml and replace its contents with default password variables:
---
db_root_password: "change-me-root-password"
db_app_password: "change-me-app-password"
Why use defaults for passwords?
The defaults/main.yml file provides fallback values that can be overridden at runtime. In a production environment, you would pass real passwords via Ansible Vault or environment variables instead of storing them in plaintext.
- Save all three db role files (tasks/main.yml, handlers/main.yml, defaults/main.yml).
- Verify all three roles exist by running:
ls ansible/roles/
You should see web, app, and db directories listed.
Missing a role directory?
Make sure you ran ansible-galaxy role init from inside the ansible/roles/ directory each time. If a role is missing, cd back into ansible/roles/ and run the init command again for the missing role name.
Help me recreate the missing role scaffold
✔️ Awesome, I've got everything!
Great. Double check you have saved all role files before moving on.
ⓧ I'd like to double check the full code
Here is the complete content of each role file for comparison.
ansible/roles/web/tasks/main.yml:
---
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Start and enable Nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Allow HTTP through UFW
community.general.ufw:
rule: allow
port: "80"
proto: tcp
- name: Allow HTTPS through UFW
community.general.ufw:
rule: allow
port: "443"
proto: tcp
- name: Configure Nginx reverse proxy
ansible.builtin.template:
src: nginx_reverse_proxy.conf.j2
dest: /etc/nginx/sites-available/app
mode: "0644"
notify: Reload Nginx
- name: Enable the site configuration
ansible.builtin.file:
src: /etc/nginx/sites-available/app
dest: /etc/nginx/sites-enabled/app
state: link
notify: Reload Nginx
ansible/roles/web/handlers/main.yml:
---
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
ansible/roles/web/templates/nginx_reverse_proxy.conf.j2:
server {
listen 80;
server_name _;
location / {
proxy_pass http://{{ hostvars[groups['tag_Role_app'][0]]['private_ip_address'] }}:8080;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
ansible/roles/app/tasks/main.yml:
---
- name: Install Docker prerequisites
ansible.builtin.apt:
name:
- ca-certificates
- curl
- gnupg
state: present
- name: Add Docker GPG key
ansible.builtin.apt_key:
url: https://download.docker.com/linux/ubuntu/gpg
state: present
- name: Add Docker repository
ansible.builtin.apt_repository:
repo: "deb https://download.docker.com/linux/ubuntu {{ ansible_distribution_release }} stable"
state: present
- name: Install Docker
ansible.builtin.apt:
name:
- docker-ce
- docker-ce-cli
- containerd.io
state: present
update_cache: true
- name: Start and enable Docker
ansible.builtin.service:
name: docker
state: started
enabled: true
- name: Add ubuntu user to docker group
ansible.builtin.user:
name: ubuntu
groups: docker
append: true
- name: Allow port 8080 through UFW
community.general.ufw:
rule: allow
port: "8080"
proto: tcp
- name: Run a sample container
community.docker.docker_container:
name: sample-app
image: nginx:alpine
state: started
ports:
- "8080:80"
restart_policy: unless-stopped
ansible/roles/db/tasks/main.yml:
---
- name: Install MariaDB server
ansible.builtin.apt:
name:
- mariadb-server
- mariadb-client
- python3-pymysql
state: present
- name: Start and enable MariaDB
ansible.builtin.service:
name: mariadb
state: started
enabled: true
- name: Secure MariaDB - set root password
community.mysql.mysql_user:
name: root
password: "{{ db_root_password }}"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Secure MariaDB - remove anonymous users
community.mysql.mysql_user:
name: ""
host_all: true
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Secure MariaDB - remove test database
community.mysql.mysql_db:
name: test
login_unix_socket: /var/run/mysqld/mysqld.sock
state: absent
- name: Create application database
community.mysql.mysql_db:
name: appdb
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Create application database user
community.mysql.mysql_user:
name: appuser
password: "{{ db_app_password }}"
priv: "appdb.*:ALL"
host: "10.0.2.%"
login_unix_socket: /var/run/mysqld/mysqld.sock
state: present
- name: Bind MariaDB to private IP only
ansible.builtin.lineinfile:
path: /etc/mysql/mariadb.conf.d/50-server.cnf
regexp: "^bind-address"
line: "bind-address = {{ ansible_default_ipv4.address }}"
notify: Restart MariaDB
- name: Allow MariaDB port through UFW
community.general.ufw:
rule: allow
port: "3306"
proto: tcp
ansible/roles/db/handlers/main.yml:
---
- name: Restart MariaDB
ansible.builtin.service:
name: mariadb
state: restarted
ansible/roles/db/defaults/main.yml:
---
db_root_password: "change-me-root-password"
db_app_password: "change-me-app-password"
Your three Ansible roles are ready. Next up, you will write the hardening playbook and the main site playbook that ties everything together into a single deployment command.
Write the Hardening Playbook and Run the Full Pipeline
Your three tier-specific roles are ready, but every production server also needs a security baseline. Without SSH hardening and a firewall, your instances are exposed to brute-force attacks and unauthorized access.
In this step you will create a common role that locks down all servers, tie every role together in a master playbook, and run the full pipeline end-to-end.
In this step, get ready to:
- Create a common role that hardens SSH and configures the UFW firewall on every server.
- Write the master playbook that orchestrates all roles against the correct host groups.
- Run the full Ansible pipeline and verify each tier is configured correctly.
Create the common role for server hardening
The common role applies a security baseline to every server in your fleet. It disables risky SSH defaults, sets up a dedicated deploy user, and enables a deny-all firewall with only SSH allowed through.
- Scaffold the common role directory structure by running this command:
ansible-galaxy role init ansible/roles/common
What does this command do?
The ansible-galaxy role init command creates the standard role directory layout including tasks/, handlers/, defaults/, and other subdirectories. You only need tasks/main.yml and handlers/main.yml for this role.
- Confirm the directory was created by running:
ls ansible/roles/common
You should see directories like defaults, handlers, tasks, templates, and others in the output.
- Open ansible/roles/common/tasks/main.yml in your editor and replace its contents with the system setup tasks:
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install common packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- unattended-upgrades
state: present
- name: Create deploy user
ansible.builtin.user:
name: deploy
groups: sudo
shell: /bin/bash
create_home: true
- name: Add deploy user SSH key
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', '~/.ssh/deployer_key.pub') }}"
What does this code do?
- The first task refreshes the apt package cache so installs pull the latest versions.
- The second task installs ufw (firewall), fail2ban (brute-force protection), and unattended-upgrades (automatic security patches).
- The third task creates a deploy user with sudo privileges for future automation without using root.
- The fourth task adds your SSH public key to the deploy user so you can authenticate without a password.
Now add the SSH hardening and firewall rules below the tasks you just pasted.
- Add the following security hardening tasks to the bottom of the same file:
- name: Harden SSH - disable root login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PermitRootLogin"
line: "PermitRootLogin no"
state: present
notify: Restart sshd
- name: Harden SSH - disable password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PasswordAuthentication"
line: "PasswordAuthentication no"
state: present
notify: Restart sshd
- name: Enable UFW
community.general.ufw:
state: enabled
policy: deny
- name: Allow SSH through UFW
community.general.ufw:
rule: allow
port: "22"
proto: tcp
What does this code do?
- The SSH tasks modify /etc/ssh/sshd_config to block root login and password-based authentication. Only key-based SSH connections will work after this.
- The notify: Restart sshd triggers a handler to restart the SSH service so the new settings take effect.
- The UFW tasks enable the firewall with a default deny-all policy, then open port 22 for SSH so you can still connect.
- Save ansible/roles/common/tasks/main.yml.
Seeing YAML indentation errors?
Ansible YAML files are sensitive to spacing. Make sure the notify: line is indented at the same level as ansible.builtin.lineinfile: (two spaces in from the task name). Each task starts with - name: at the root indentation level.
Help me fix YAML indentation in my Ansible tasks file.
Next, create the handler that restarts the SSH daemon when configuration changes.
- Open ansible/roles/common/handlers/main.yml and replace its contents with:
---
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
What is a handler?
Handlers are tasks that only run when triggered by a notify directive. If none of the SSH config lines actually change (because they already match), the handler is skipped entirely. This avoids unnecessary service restarts.
- Save ansible/roles/common/handlers/main.yml.
✔️ Awesome, I've got everything!
Great. Double check you have saved both files in the common role.
ⓧ I'd like to double check the full code
Here is the complete ansible/roles/common/tasks/main.yml:
---
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install common packages
ansible.builtin.apt:
name:
- ufw
- fail2ban
- unattended-upgrades
state: present
- name: Create deploy user
ansible.builtin.user:
name: deploy
groups: sudo
shell: /bin/bash
create_home: true
- name: Add deploy user SSH key
ansible.posix.authorized_key:
user: deploy
key: "{{ lookup('file', '~/.ssh/deployer_key.pub') }}"
- name: Harden SSH - disable root login
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PermitRootLogin"
line: "PermitRootLogin no"
state: present
notify: Restart sshd
- name: Harden SSH - disable password authentication
ansible.builtin.lineinfile:
path: /etc/ssh/sshd_config
regexp: "^PasswordAuthentication"
line: "PasswordAuthentication no"
state: present
notify: Restart sshd
- name: Enable UFW
community.general.ufw:
state: enabled
policy: deny
- name: Allow SSH through UFW
community.general.ufw:
rule: allow
port: "22"
proto: tcp
And the complete ansible/roles/common/handlers/main.yml:
---
- name: Restart sshd
ansible.builtin.service:
name: sshd
state: restarted
Write the master playbook
The master playbook is the single entry point that orchestrates all four roles against the correct host groups. Ansible applies plays in order, so the common hardening runs first on every server before the tier-specific configuration begins.
- Create ansible/site.yml and add the following content:
---
- name: Apply common hardening to all servers
hosts: all
become: true
roles:
- common
- name: Configure web servers
hosts: tag_Role_web
become: true
roles:
- web
- name: Configure app servers
hosts: tag_Role_app
become: true
roles:
- app
- name: Configure database servers
hosts: tag_Role_db
become: true
roles:
- db
What does this playbook do?
- The first play targets hosts: all and applies the common role. Every server gets SSH hardening and UFW regardless of tier.
- The remaining three plays target the dynamic inventory groups (tag_Role_web, tag_Role_app, tag_Role_db) and apply each tier-specific role.
- The become: true directive runs all tasks with sudo privileges, which is required for package installation and service management.
- Save ansible/site.yml.
✔️ Awesome, I've got everything!
Great. Make sure you have saved the file before continuing.
ⓧ I'd like to double check the full code
Here is the complete ansible/site.yml:
---
- name: Apply common hardening to all servers
hosts: all
become: true
roles:
- common
- name: Configure web servers
hosts: tag_Role_web
become: true
roles:
- web
- name: Configure app servers
hosts: tag_Role_app
become: true
roles:
- app
- name: Configure database servers
hosts: tag_Role_db
become: true
roles:
- db
Run the full pipeline and verify each tier
Everything is wired up. Before running the playbook, you will create a deployment script that automates the entire provision-and-configure flow for future use.
- Create deploy.sh in the root of your project directory with the following content:
#!/bin/bash
set -e
echo "=== Multi-Tier Infrastructure Deployment ==="
echo ""
# Step 1: Terraform
echo "[1/3] Provisioning infrastructure with Terraform..."
terraform init
terraform apply -auto-approve
echo ""
echo "[2/3] Waiting for instances to initialize (60 seconds)..."
sleep 60
# Step 2: Ansible
echo "[3/3] Configuring servers with Ansible..."
ansible-playbook -i ansible/inventory/aws_ec2.yml ansible/site.yml
echo ""
echo "=== Deployment Complete ==="
echo "Web server: http://$(terraform output -raw web_public_ip)"
What does this script do?
This script runs the full infrastructure pipeline in one command. It provisions resources with Terraform, waits 60 seconds for instances to boot and become SSH-ready, then configures everything with Ansible.
The set -e flag causes the script to exit immediately if any command fails. This prevents Ansible from running against instances that were never created.
- Save deploy.sh and make it executable by running:
chmod +x deploy.sh
For this first run, your infrastructure is already provisioned from the earlier step. You will run Ansible directly to configure all three servers.
- Run the master playbook against your live infrastructure by running:
ansible-playbook -i ansible/inventory/aws_ec2.yml ansible/site.yml
What should I see?
Ansible will output a play recap showing each host and its task results. You should see ok and changed counts for all three servers with zero failed tasks. The entire run takes a few minutes because each server downloads and installs packages.
Seeing connection timeouts or unreachable hosts?
If the app or db instances are unreachable, remember they are in private subnets. Ansible needs to connect through the web server as a bastion host. Add a ProxyJump configuration in your ~/.ssh/config or verify that the ansible_host compose rule in your inventory is resolving to the correct IP addresses.
Help me troubleshoot Ansible connection failures to private subnet instances.
Once the playbook completes successfully, verify each tier is configured correctly.
- SSH into the web server and check that Nginx is running:
ssh -i ~/.ssh/deployer_key ubuntu@$(terraform output -raw web_public_ip) "systemctl status nginx"
You should see active (running) in the output, confirming Nginx is serving traffic on the web tier.
- Verify the app server has Docker running with the sample container:
ssh -i ~/.ssh/deployer_key -o ProxyJump=ubuntu@$(terraform output -raw web_public_ip) ubuntu@$(terraform output -raw app_private_ip) "docker ps"
You should see a container named sample-app running the nginx:alpine image with port 8080 mapped.
- Verify the database server has MariaDB running with the application database:
ssh -i ~/.ssh/deployer_key -o ProxyJump=ubuntu@$(terraform output -raw web_public_ip) ubuntu@$(terraform output -raw db_private_ip) "sudo mariadb -e 'SHOW DATABASES;'"
You should see appdb in the list of databases, confirming MariaDB is configured and your application database exists.
Docker ps showing no containers?
If the sample-app container is missing, the Docker pull might have failed due to a network timeout through the NAT Gateway. SSH into the app server and run docker pull nginx:alpine manually to test connectivity. If that works, re-run the playbook.
Help me troubleshoot why my Docker container is not running on the app server.
✔️ Awesome, I've got everything!
Great. Make sure you saved deploy.sh as well.
ⓧ I'd like to double check the full code
Here is the complete deploy.sh:
#!/bin/bash
set -e
echo "=== Multi-Tier Infrastructure Deployment ==="
echo ""
# Step 1: Terraform
echo "[1/3] Provisioning infrastructure with Terraform..."
terraform init
terraform apply -auto-approve
echo ""
echo "[2/3] Waiting for instances to initialize (60 seconds)..."
sleep 60
# Step 2: Ansible
echo "[3/3] Configuring servers with Ansible..."
ansible-playbook -i ansible/inventory/aws_ec2.yml ansible/site.yml
echo ""
echo "=== Deployment Complete ==="
echo "Web server: http://$(terraform output -raw web_public_ip)"
Your entire 3-tier infrastructure is now provisioned and configured with a single pipeline. Next up, you can take on the Secret Mission to add load balancing and auto scaling for high availability.
Secret mission
Add a Load Balancer and Auto Scaling Basics
Your infrastructure works, but real production systems need high availability. What happens if the web server gets overwhelmed or the app tier goes down? In this mission, you will add an Application Load Balancer in front of the web tier and configure an Auto Scaling Group so the app tier can scale automatically.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running or delete them entirely. This project provisions paid AWS resources that accumulate charges every hour they remain active.
Cost warning
Your NAT Gateway alone costs ~$0.045/hour, and each t3.micro instance costs ~$0.0104/hour. With all resources running (including auto-scaled instances from the Secret Mission), you could accumulate $2-3/day in charges.
Destroy your resources immediately after you finish exploring. You can always re-provision everything in minutes with a single command.
Resources you used:
- 3+ EC2 instances (t3.micro), including auto-scaled instances.
- 1 NAT Gateway with an Elastic IP.
- 1 Application Load Balancer (if you completed the Secret Mission).
- 1 Auto Scaling Group and Launch Template (if you completed the Secret Mission).
- 1 VPC with subnets, route tables, security groups, and Internet Gateway.
- SSH key pair at ~/.ssh/deployer_key and ~/.ssh/deployer_key.pub.
- Local project files (Terraform configs, Ansible roles, inventory).
Keep everything running
No action needed. Choose this if you are still actively experimenting with your infrastructure or plan to continue shortly.
- Your EC2 instances, NAT Gateway, and ALB will continue to incur hourly charges.
- Monitor your AWS billing dashboard to keep track of costs.
- Terraform and Ansible remain installed on your machine for future use.
Pause - I'll come back to this later
Stop the running charges while keeping your configuration files intact so you can re-deploy later.
- Run the following command from your project directory to destroy all AWS resources:
- This removes all cloud resources but keeps your local Terraform and Ansible files intact.
terraform destroy
Terraform will show you a plan of what it will destroy. Type yes when prompted to confirm.
- When you want to come back, just run terraform apply followed by ansible-playbook to re-provision everything in minutes.
Delete - I don't want to use this again
Remove all project resources from AWS and delete local project files completely.
- Destroy all AWS resources by running this command from your project directory:
terraform destroy
Type yes when prompted to confirm the destruction of all resources.
- Remove the SSH key pair you created for this project:
rm ~/.ssh/deployer_key ~/.ssh/deployer_key.pub
- Remove the AWS key pair registration (Terraform already handles this during destroy).
- Delete your project directory by navigating to its parent folder and removing it:
cd ~/Desktop
ls
- Identify your project folder in the output, then delete it:
rm -rf [[PROJECT_FOLDER="your project folder name"]]
- Uninstall Terraform:
sudo apt remove terraform
- Uninstall Ansible and boto3:
pip uninstall ansible boto3
- Remove the amazon.aws Ansible collection:
rm -rf ~/.ansible/collections/ansible_collections/amazon
- Uninstall the AWS CLI:
sudo apt remove awscli
Nice Work!
Nice Work!
You have built a complete infrastructure automation pipeline that provisions and configures a production-style 3-tier architecture on AWS with a single command.
You've learned how to:
- Provisioned a 3-tier VPC architecture with Terraform, including networking, compute, and layered security groups.
- Configured servers automatically using Ansible roles for Nginx, Docker, and MariaDB across web, app, and database tiers.
- Implemented dynamic inventory with the amazon.aws.aws_ec2 plugin to auto-discover cloud infrastructure and applied SSH hardening with UFW firewall rules across all tiers.
- Added an Application Load Balancer and Auto Scaling Group for high availability, with Ansible automatically discovering new auto-scaled instances.
Ready to quiz yourself?