Project: Two-Server Web Stack with Terraform & Ansible
In this project you will build a small but realistic system from nothing: Terraform creates two Ubuntu servers on AWS with tight firewall rules, writes an Ansible inventory, and Ansible turns one server into a web server and the other into a PostgreSQL database server. By the end you will have used almost every idea from the course in one place, and you will tear it all down with one command.
What you will build
Internet
|
port 80 (HTTP)
|
+-----------------------+ port 5432, only from web1
| web1: Nginx | ----------------------------------> +----------------------+
| status page | | db1: PostgreSQL |
+-----------------------+ +----------------------+
SSH only from your IP SSH only from your IP- Terraform owns: the SSH key pair, two security groups, their rules, two EC2 instances and the generated inventory file.
- Ansible owns: packages, time zone, PostgreSQL settings, the database and user, Nginx and the status page.
Think of a new tea stall at a Pune railway station. The railway (Terraform) builds the stall and the storage room behind it, with a door that opens only from the stall. The stall owner (Ansible) installs the stove, stocks the shelves and puts up the menu board.
Before you start
You need:
- An AWS account you are allowed to use, with credentials on your machine through single sign-on or a profile (never access keys typed into files). See IAM Roles, Identity Center & Least Privilege.
- Terraform 1.14 or newer and Ansible (
ansiblepackage), set up as in Install Terraform, OpenTofu & Ansible. - An SSH key pair, for example created with
ssh-keygen -t ed25519 -f ~/.ssh/chaicart. - Your public IP address, so SSH is open only to you.
Step 1: Create the project layout
mkdir -p ~/iac-course/chaicart/{infra,ansible} && cd ~/iac-course/chaicart
git init
printf '.terraform/\n*.tfstate\n*.tfstate.*\n*.tfplan\nansible/inventory.ini\n.vault_pass\n' > .gitignoreThe finished project will look like this:
chaicart/
├── infra/
│ ├── versions.tf variables.tf network.tf servers.tf outputs.tf
│ └── inventory.tftpl
└── ansible/
├── ansible.cfg site.yml
├── group_vars/all/vars.yml group_vars/all/vault.yml
└── roles/common roles/postgres roles/webThe generated inventory is ignored by Git, because Terraform can always recreate it.
Step 2: Versions, provider and variables
infra/versions.tf:
terraform {
required_version = ">= 1.14"
required_providers {
aws = { source = "hashicorp/aws", version = "~> 6.0" }
local = { source = "hashicorp/local", version = "~> 2.9" }
}
}
provider "aws" {
region = var.region
default_tags {
tags = { Project = "chaicart", ManagedBy = "terraform" }
}
}infra/variables.tf:
variable "region" {
type = string
default = "ap-south-1"
}
variable "my_ip_cidr" {
type = string
description = "Your public IP with /32, for SSH access"
validation {
condition = can(cidrhost(var.my_ip_cidr, 0)) && endswith(var.my_ip_cidr, "/32")
error_message = "Use a single address such as 203.0.113.10/32."
}
}
variable "ssh_key_path" {
type = string
default = "~/.ssh/chaicart"
}
variable "instance_type" {
type = string
default = "t3.micro"
}The validation refuses anything wider than one address, so you cannot open SSH to the whole internet by mistake. For a real company, state would live in a remote backend as in Remote Backends & State Locking; local state is fine for this one-person project.
Step 3: Network and firewall rules
To keep the project focused, it uses the account's default VPC, found with data sources. infra/network.tf:
data "aws_vpc" "default" {
default = true
}
data "aws_subnets" "default" {
filter {
name = "vpc-id"
values = [data.aws_vpc.default.id]
}
}
resource "aws_security_group" "web" {
name = "chaicart-web"
vpc_id = data.aws_vpc.default.id
}
resource "aws_security_group" "db" {
name = "chaicart-db"
vpc_id = data.aws_vpc.default.id
}
locals {
ssh_rules = {
web = aws_security_group.web.id
db = aws_security_group.db.id
}
}
resource "aws_vpc_security_group_ingress_rule" "ssh" {
for_each = local.ssh_rules
security_group_id = each.value
cidr_ipv4 = var.my_ip_cidr
from_port = 22
to_port = 22
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_ingress_rule" "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" "postgres_from_web" {
security_group_id = aws_security_group.db.id
referenced_security_group_id = aws_security_group.web.id
from_port = 5432
to_port = 5432
ip_protocol = "tcp"
}
resource "aws_vpc_security_group_egress_rule" "all_out" {
for_each = local.ssh_rules
security_group_id = each.value
cidr_ipv4 = "0.0.0.0/0"
ip_protocol = "-1"
}The key line is referenced_security_group_id: the database port accepts traffic only from servers in the web security group, not from any IP address. Notice for_each creating the SSH and outbound rules for both groups from one block each.
Step 4: The servers
infra/servers.tf finds the latest Ubuntu 24.04 image, uploads your public key and creates both servers:
data "aws_ami" "ubuntu" {
most_recent = true
owners = ["099720109477"]
filter {
name = "name"
values = ["ubuntu/images/hvm-ssd-gp3/ubuntu-noble-24.04-amd64-server-*"]
}
}
resource "aws_key_pair" "deployer" {
key_name = "chaicart-deployer"
public_key = file(pathexpand("${var.ssh_key_path}.pub"))
}
locals {
servers = {
web = aws_security_group.web.id
db = aws_security_group.db.id
}
}
resource "aws_instance" "this" {
for_each = local.servers
ami = data.aws_ami.ubuntu.id
instance_type = var.instance_type
key_name = aws_key_pair.deployer.key_name
subnet_id = data.aws_subnets.default.ids[0]
vpc_security_group_ids = [each.value]
metadata_options {
http_tokens = "required"
}
root_block_device {
volume_size = 10
encrypted = true
}
tags = { Name = "chaicart-${each.key}", Role = each.key }
}Both servers go in the same subnet for simplicity. http_tokens = "required" turns on IMDSv2, the safer version of the instance metadata service, and the disks are encrypted. The Role tag would also let a dynamic inventory group the servers.
Step 5: Generate the inventory and outputs
infra/inventory.tftpl:
[web]
web1 ansible_host=${web_public_ip}
[db]
db1 ansible_host=${db_public_ip}
[all:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=${ssh_key_path}
ansible_python_interpreter=/usr/bin/python3
web_private_ip=${web_private_ip}
db_private_ip=${db_private_ip}infra/outputs.tf renders it into the Ansible folder and prints the useful values:
resource "local_file" "inventory" {
filename = "${path.module}/../ansible/inventory.ini"
file_permission = "0644"
content = templatefile("${path.module}/inventory.tftpl", {
web_public_ip = aws_instance.this["web"].public_ip
db_public_ip = aws_instance.this["db"].public_ip
web_private_ip = aws_instance.this["web"].private_ip
db_private_ip = aws_instance.this["db"].private_ip
ssh_key_path = pathexpand(var.ssh_key_path)
})
}
output "site_url" {
value = "http://${aws_instance.this["web"].public_ip}"
}
output "db_private_ip" {
value = aws_instance.this["db"].private_ip
}Step 6: Init, plan and apply
Put your own IP in infra/terraform.tfvars as my_ip_cidr = "203.0.113.10/32" (that address is a documentation example), then run:
cd infra
terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplanPlan: 12 to add, 0 to change, 0 to destroy.
The output is trimmed to the summary line. Check that 12 is right before you continue:
| Resources | Count |
|---|---|
| Key pair | 1 |
| Security groups (web, db) | 2 |
| Ingress rules (SSH for web, SSH for db, HTTP, PostgreSQL) | 4 |
| Egress rules (web, db) | 2 |
| EC2 instances (web, db) | 2 |
| Inventory file | 1 |
Data sources (the VPC, subnets and AMI) are only read, so they are not counted. Read the plan, look for anything unexpected, then apply the saved plan:
terraform apply tfplanApply complete! Resources: 12 added, 0 changed, 0 destroyed. Outputs: db_private_ip = "172.31.5.20" site_url = "http://203.0.113.25"
Your addresses will differ; these are examples. ../ansible/inventory.ini now exists with the real addresses filled in.
Step 7: Configure Ansible and the vault
ansible/ansible.cfg:
[defaults]
inventory = inventory.ini
roles_path = roles
[ssh_connection]
ssh_args = -o StrictHostKeyChecking=accept-newaccept-new trusts a server's SSH host key the first time and refuses if it changes later. That is safer than switching host key checking off completely.
ansible/group_vars/all/vars.yml:
db_name: chaicart
db_user: chaicart_app
db_password: "{{ vault_db_password }}"
pg_version: 16PostgreSQL 16 is the version in Ubuntu 24.04's own packages. Create the encrypted file and add vault_db_password with a long random value:
cd ../ansible
ansible-vault create group_vars/all/vault.ymlStep 8: Write the three roles
Create the skeletons with ansible-galaxy role init roles/common (and the same for postgres and web), then fill in tasks/main.yml for each.
roles/common/tasks/main.yml:
- name: Install base packages
ansible.builtin.apt:
name: [curl, htop, unattended-upgrades]
state: present
update_cache: true
cache_valid_time: 3600
- name: Set the time zone to IST
community.general.timezone:
name: Asia/Kolkataroles/postgres/tasks/main.yml:
- name: Install PostgreSQL
ansible.builtin.apt:
name: [postgresql, python3-psycopg2]
state: present
update_cache: true
cache_valid_time: 3600
- name: Listen on the private network
ansible.builtin.lineinfile:
path: "/etc/postgresql/{{ pg_version }}/main/postgresql.conf"
regexp: "^#?listen_addresses"
line: "listen_addresses = 'localhost,{{ db_private_ip }}'"
notify: Restart PostgreSQL
- name: Allow the web server to connect
community.postgresql.postgresql_pg_hba:
dest: "/etc/postgresql/{{ pg_version }}/main/pg_hba.conf"
contype: host
databases: "{{ db_name }}"
users: "{{ db_user }}"
address: "{{ web_private_ip }}/32"
method: scram-sha-256
notify: Restart PostgreSQL
- name: Create the application user
community.postgresql.postgresql_user:
name: "{{ db_user }}"
password: "{{ db_password }}"
become_user: postgres
no_log: true
- name: Create the application database
community.postgresql.postgresql_db:
name: "{{ db_name }}"
owner: "{{ db_user }}"
become_user: postgresAnd roles/postgres/handlers/main.yml:
- name: Restart PostgreSQL
ansible.builtin.service:
name: postgresql
state: restartedThe database accepts connections only on its private address, only for one user and database, only from the web server's private IP, and only with a password. Together with the security group, that is three layers of protection.
roles/web/tasks/main.yml:
- name: Install Nginx and the PostgreSQL client
ansible.builtin.apt:
name: [nginx, postgresql-client]
state: present
update_cache: true
cache_valid_time: 3600
- name: Publish the status page
ansible.builtin.template:
src: index.html.j2
dest: /var/www/html/index.html
mode: "0644"
- name: Check that the database accepts connections
ansible.builtin.command: "pg_isready -h {{ db_private_ip }} -p 5432"
register: db_check
changed_when: false
- name: Show the database check
ansible.builtin.debug:
msg: "{{ db_check.stdout }}"roles/web/templates/index.html.j2:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="utf-8"><title>ChaiCart status</title></head>
<body>
<h1>ChaiCart is running</h1>
<p>Web server: {{ inventory_hostname }} ({{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }})</p>
<p>Database server: {{ db_private_ip }}, database {{ db_name }}</p>
</body>
</html>Step 9: The site playbook
ansible/site.yml ties the roles to the hosts:
- name: Wait for the new servers
hosts: all
gather_facts: false
tasks:
- name: Wait until SSH works
ansible.builtin.wait_for_connection:
timeout: 300
- name: Base setup for all servers
hosts: all
become: true
roles: [common]
- name: Database server
hosts: db
become: true
roles: [postgres]
- name: Web server
hosts: web
become: true
roles: [web]Run a syntax check, then the real thing:
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --ask-vault-passPLAY [Database server] *********************************************************
TASK [Gathering Facts] *********************************************************
ok: [db1]
TASK [postgres : Install PostgreSQL] *******************************************
changed: [db1]
TASK [postgres : Listen on the private network] ********************************
changed: [db1]
TASK [postgres : Allow the web server to connect] ******************************
changed: [db1]
TASK [postgres : Create the application user] **********************************
changed: [db1]
TASK [postgres : Create the application database] ******************************
changed: [db1]
RUNNING HANDLER [postgres : Restart PostgreSQL] ********************************
changed: [db1]
PLAY [Web server] **************************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
TASK [web : Install Nginx and the PostgreSQL client] ***************************
changed: [web1]
TASK [web : Publish the status page] *******************************************
changed: [web1]
TASK [web : Check that the database accepts connections] ***********************
ok: [web1]
TASK [web : Show the database check] *******************************************
ok: [web1] => {
"msg": "172.31.5.20:5432 - accepting connections"
}
PLAY RECAP *********************************************************************
db1 : ok=11 changed=8 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web1 : ok=9 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0The output is trimmed to the last two plays. The handler restarted PostgreSQL once, after both config changes. The message accepting connections proves that the web server can reach the database through the firewall and pg_hba.conf rules.
Step 10: Verify, rerun and check for drift
Open the site from your own terminal:
curl -s "$(cd ../infra && terraform output -raw site_url)" | grep h1<h1>ChaiCart is running</h1>
Now prove both tools are idempotent:
- Run
ansible-playbook site.yml --ask-vault-passagain. The recap should showchanged=0for both hosts. - Run
terraform plan -detailed-exitcodeininfra, thenecho $?. It should print0.
Finally, simulate drift: SSH to db1 and edit pg_hba.conf by hand, or open port 5432 to the world in the AWS console. Then run ansible-playbook site.yml --check --diff or terraform plan and watch each tool point at the change. Fix it by applying the code again.
Step 11: Destroy everything
When you are done, remove every resource Terraform created:
cd ../infra
terraform destroyDestroy complete! Resources: 12 destroyed.
Check the EC2 console for the Mumbai region to confirm no chaicart instances remain. The generated inventory file is deleted too, because Terraform created it.
Ideas to extend the project
- Move state to an S3 backend with
use_lockfile = true, and add the pipeline from CI/CD for IaC. - Put the database in a private subnet with no public IP, and reach it through the web server as an SSH jump host.
- Replace the generated inventory with the
amazon.aws.aws_ec2dynamic inventory, grouping by theRoletag. - Turn the Terraform code into a module and create
devandprodfolders that call it. - Add
terraform testwith a mock AWS provider, and Molecule tests for the roles.
Key points
- Terraform built the key pair, firewalls, servers and the inventory; Ansible configured everything inside the servers.
- Security group references and
pg_hba.confrules let only the web server reach the database. templatefileturned Terraform outputs into an Ansible inventory, with no hand-typed IP addresses.- Roles, Vault, handlers and
no_logkept the Ansible code reusable and secrets protected. - A second run with
changed=0and a plan with exit code 0 prove the setup is idempotent. terraform destroyremoves everything, which keeps practice projects safe and clean.
Exercise
Add a second web server to the project. Change Terraform so there are two web instances (web1, web2), update the inventory template to list both, and make sure the database allows connections from both private IPs. Write the changed parts only.
Show solution
locals {
servers = {
web1 = aws_security_group.web.id
web2 = aws_security_group.web.id
db = aws_security_group.db.id
}
web_ips = { for k, s in aws_instance.this : k => s if startswith(k, "web") }
}
# in the templatefile call
web_servers = { for k, s in local.web_ips : k => s.public_ip }
web_private = [for s in local.web_ips : s.private_ip][web]
%{ for name, ip in web_servers ~}
${name} ansible_host=${ip}
%{ endfor ~}- name: Allow the web servers to connect
community.postgresql.postgresql_pg_hba:
dest: "/etc/postgresql/{{ pg_version }}/main/pg_hba.conf"
contype: host
databases: "{{ db_name }}"
users: "{{ db_user }}"
address: "{{ item }}/32"
method: scram-sha-256
loop: "{{ web_private_ips }}"
notify: Restart PostgreSQLPass web_private_ips into the inventory as a JSON list (for example web_private_ips=${jsonencode(web_private)} under [all:vars]), and replace aws_instance.this["web"] in the outputs with "web1". The security group rule needs no change, because it already allows every server in the web group.
Quick Quiz
1. Why does the PostgreSQL rule use referenced_security_group_id instead of an IP range?
Referencing a group follows the servers even if their addresses change.
2. What does StrictHostKeyChecking=accept-new do?
It is a safer middle ground for freshly created servers.
3. How do you prove the finished setup is idempotent?
Both tools should find nothing to change on a second run.
Learn Terraform & Ansible with a mentor
Free lessons get you started. Our Cloud & DevOps training adds real projects and code reviews from working developers.
- Project-based learning
- Mentor code reviews
- Free counselling call
