EthicsComputerCourses
Join training +91 90532 10052

Project: Two-Server Web Stack with Terraform & Ansible

Terraform & Ansible · Lesson 29 of 29 Advanced 12 min read Updated

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

Text
        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 (ansible package), 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

Terminal
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' > .gitignore

The finished project will look like this:

Text
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/web

The generated inventory is ignored by Git, because Terraform can always recreate it.

Step 2: Versions, provider and variables

infra/versions.tf:

HCL (Terraform)
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:

HCL (Terraform)
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:

HCL (Terraform)
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:

HCL (Terraform)
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:

Text
[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:

HCL (Terraform)
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:

Terminal
cd infra
terraform init
terraform fmt -check
terraform validate
terraform plan -out=tfplan
Output
Plan: 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:

ResourcesCount
Key pair1
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 file1

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:

Terminal
terraform apply tfplan
Output
Apply 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:

Text
[defaults]
inventory = inventory.ini
roles_path = roles

[ssh_connection]
ssh_args = -o StrictHostKeyChecking=accept-new

accept-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:

YAML
db_name: chaicart
db_user: chaicart_app
db_password: "{{ vault_db_password }}"
pg_version: 16

PostgreSQL 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:

Terminal
cd ../ansible
ansible-vault create group_vars/all/vault.yml

Step 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:

YAML
- 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/Kolkata

roles/postgres/tasks/main.yml:

YAML
- 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: postgres

And roles/postgres/handlers/main.yml:

YAML
- name: Restart PostgreSQL
  ansible.builtin.service:
    name: postgresql
    state: restarted

The 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:

YAML
- 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:

HTML
<!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:

YAML
- 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:

Terminal
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --ask-vault-pass
Output
PLAY [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=0

The 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:

Terminal
curl -s "$(cd ../infra && terraform output -raw site_url)" | grep h1
Output
<h1>ChaiCart is running</h1>

Now prove both tools are idempotent:

  1. Run ansible-playbook site.yml --ask-vault-pass again. The recap should show changed=0 for both hosts.
  2. Run terraform plan -detailed-exitcode in infra, then echo $?. It should print 0.

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:

Terminal
cd ../infra
terraform destroy
Output
Destroy 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_ec2 dynamic inventory, grouping by the Role tag.
  • Turn the Terraform code into a module and create dev and prod folders that call it.
  • Add terraform test with 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.conf rules let only the web server reach the database.
  • templatefile turned Terraform outputs into an Ansible inventory, with no hand-typed IP addresses.
  • Roles, Vault, handlers and no_log kept the Ansible code reusable and secrets protected.
  • A second run with changed=0 and a plan with exit code 0 prove the setup is idempotent.
  • terraform destroy removes 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
HCL (Terraform)
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]
Text
[web]
%{ for name, ip in web_servers ~}
${name} ansible_host=${ip}
%{ endfor ~}
YAML
- 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 PostgreSQL

Pass 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?

2. What does StrictHostKeyChecking=accept-new do?

3. How do you prove the finished setup is idempotent?

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