What You'll Build
This capstone lab for Module 5 implements the complete provision-then-configure pattern as a working pipeline. You will use Terraform to provision two EC2 instances in a VPC with SSM access, then immediately configure them with an Ansible role (cricket_nginx) via the dynamic EC2 inventory plugin and SSM Session Manager — the same pattern used in production IaC pipelines. The lab includes a shell script that orchestrates the full workflow: provision → wait for SSM → configure → verify → (optional) destroy. After completing this lab, you will have a working end-to-end IaC pipeline as the foundation for the Course 2 capstone project in Module 6.
Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.
Step 1 — Terraform Configuration
bash
#!/bin/bash
mkdir -p ~/cricket_full_pipeline/{terraform,ansible/roles,ansible/inventory}
cd ~/cricket_full_pipeline
# Terraform: provision 2 EC2 instances tagged for Ansible discovery
cat > terraform/main.tf << 'TF'
terraform {
required_version = ">= 1.6.0"
required_providers { aws = { source = "hashicorp/aws", version = "~> 5.0" } }
}
provider "aws" { region = "ap-south-1" }
data "aws_ami" "al2023" {
most_recent = true; owners = ["amazon"]
filter { name = "name"; values = ["al2023-ami-*-x86_64"] }
filter { name = "state"; values = ["available"] }
}
data "aws_vpc" "default" { default = true }
resource "aws_iam_role" "ec2_ssm" {
name = "cricket-pipeline-ec2-ssm"
assume_role_policy = jsonencode({
Statement = [{ Effect = "Allow"; Principal = { Service = "ec2.amazonaws.com" }; Action = "sts:AssumeRole" }]
})
}
resource "aws_iam_role_policy_attachment" "ssm" {
role = aws_iam_role.ec2_ssm.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ec2_ssm" {
name = "cricket-pipeline-ec2-ssm-profile"
role = aws_iam_role.ec2_ssm.name
}
resource "aws_security_group" "ec2" {
name = "cricket-pipeline-ec2-sg"
vpc_id = data.aws_vpc.default.id
egress { from_port = 443; to_port = 443; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"]; description = "HTTPS for SSM" }
egress { from_port = 80; to_port = 80; protocol = "tcp"; cidr_blocks = ["0.0.0.0/0"]; description = "HTTP for packages" }
tags = { Name = "cricket-pipeline-ec2-sg" }
}
resource "aws_instance" "api" {
count = 2
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.ec2_ssm.name
vpc_security_group_ids = [aws_security_group.ec2.id]
metadata_options { http_endpoint = "enabled"; http_tokens = "required"; http_put_response_hop_limit = 1 }
tags = {
Name = "cricket-api-pipeline-${count.index + 1}"
Project = "cricket-analytics"
Environment = "development"
Role = "api-server"
AnsibleManaged = "true"
}
}
output "instance_ids" { value = aws_instance.api[*].id }
output "instance_ips" { value = aws_instance.api[*].private_ip }
TF
echo 'Terraform configuration created'Step 2 — Ansible Configuration
yaml
# ansible/inventory/aws_ec2.yml
plugin: amazon.aws.ec2
regions: [ap-south-1]
filters:
instance-state-name: running
'tag:AnsibleManaged': 'true'
'tag:Project': cricket-analytics
hostnames: [instance-id]
keyed_groups:
- prefix: tag
key: tags
groups:
api_servers: "tags.Role == 'api-server'"
compose:
ansible_host: instance_id
ansible_connection: community.aws.aws_ssm
ansible_aws_ssm_region: placement.region
ansible_user: ec2-user
---
# ansible/ansible.cfg
[defaults]
host_key_checking = False
stdout_callback = yaml
roles_path = roles
---
# ansible/site.yml — apply cricket_nginx role to api_servers
- name: Configure Cricket API servers
hosts: api_servers
become: true
gather_facts: true
roles:
- role: cricket_nginx
vars:
nginx_port: 80
nginx_upstream_port: 8080
nginx_server_name: localhost
---
# ansible/roles/cricket_nginx/defaults/main.yml
nginx_port: 80
nginx_upstream_port: 8080
nginx_server_name: localhost
---
# ansible/roles/cricket_nginx/tasks/main.yml
- name: Install Nginx
ansible.builtin.dnf:
name: nginx
state: present
- name: Configure Nginx
ansible.builtin.copy:
content: |
server {
listen {{ nginx_port }};
server_name {{ nginx_server_name }};
location /health {
add_header Content-Type application/json;
return 200 '{"status":"ok","host":"{{ inventory_hostname }}"}' ;
}
location / {
proxy_pass http://127.0.0.1:{{ nginx_upstream_port }};
}
}
dest: /etc/nginx/conf.d/cricket.conf
mode: '0644'
notify: Reload Nginx
- name: Start and enable Nginx
ansible.builtin.service:
name: nginx
enabled: true
state: started
---
# ansible/roles/cricket_nginx/handlers/main.yml
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloadedStep 3 — Orchestration Script
bash
#!/bin/bash
# run_pipeline.sh — Complete provision-then-configure pipeline
set -euo pipefail
cd ~/cricket_full_pipeline
echo '████ PHASE 1: PROVISION INFRASTRUCTURE (Terraform) ████'
cd terraform
terraform init -input=false
terraform apply -input=false -auto-approve
INSTANCE_IDS=($(terraform output -json instance_ids | python3 -c 'import sys,json; print(" ".join(json.load(sys.stdin)))'))
echo "Provisioned instances: ${INSTANCE_IDS[*]}"
echo
echo '████ PHASE 2: WAIT FOR SSM REGISTRATION ████'
for INSTANCE_ID in "${INSTANCE_IDS[@]}"; do
echo -n "Waiting for ${INSTANCE_ID}..."
timeout 300 bash -c "
until aws ssm describe-instance-information \
--filters Key=InstanceIds,Values=${INSTANCE_ID} \
--query 'InstanceInformationList[0].PingStatus' \
--output text 2>/dev/null | grep -q Online; do
printf '.'
sleep 10
done
"
echo ' ONLINE'
done
echo
echo '████ PHASE 3: CONFIGURE WITH ANSIBLE ████'
cd ../ansible
pip install -q boto3 botocore
ansible-galaxy collection install -q amazon.aws community.aws
# Verify inventory discovers the instances
echo 'Dynamic inventory check:'
ansible-inventory -i inventory/aws_ec2.yml --graph 2>/dev/null
# Run site playbook
ansible-playbook \
-i inventory/aws_ec2.yml \
site.yml \
-v
echo
echo '████ PHASE 4: VERIFY END-TO-END ████'
ansible api_servers \
-i inventory/aws_ec2.yml \
-m ansible.builtin.command \
-a 'curl -s http://localhost/health'
echo
echo '████ IDEMPOTENCY CHECK ████'
ansible-playbook -i inventory/aws_ec2.yml site.yml 2>&1 | \
grep -E 'changed=|ok=|failed=' | \
{ CHANGED=$(grep -o 'changed=[0-9]*' | awk -F= '{sum+=$2} END{print sum}'); \
echo "Total changed on re-run: ${CHANGED:-0}"; \
[[ ${CHANGED:-0} -eq 0 ]] && echo 'PASS: Playbook is idempotent' || echo 'FAIL: Non-idempotent tasks detected'; }
echo
echo '████ CLEANUP ████'
cd ../terraform
echo 'Destroying infrastructure...'
terraform destroy -auto-approve
echo 'All resources destroyed'Analogy🏏Cricket
🏏 Think of it like cricket: The run_pipeline.sh orchestration script is the match-day run sheet that turns two independent organisations into one coherent event, and every line of it encodes a lesson from this module. 'terraform apply' is the construction firm handing over the finished venue; the explicit wait-for-SSM loop that follows is the run sheet's most experienced entry — officials do NOT take the field the moment the builders say 'done', because 'built' and 'ready for play' are different states certified by different systems (the EC2 API reports the instance running long before the SSM agent inside it has booted, registered, and become reachable; skip the wait and Ansible fails against hosts that exist but cannot yet be spoken to). Only when the venue's own operations desk confirms readiness (aws ssm describe-instance-information shows the instances Online) does the coaching stage begin: the dynamic inventory reads the plaques the builders bolted on (discovers hosts by the tags Terraform applied) and the role executes the rehearsed programme (cricket_nginx converges). The deeper habit this lab installs: between any two independent systems in a pipeline, never assume the first's 'success' means the second's preconditions hold — poll for the readiness signal the SECOND system defines, the way officials wait for the ground authority's certificate rather than the builder's handshake.
- The complete provision-then-configure pipeline: terraform apply → wait for SSM → ansible-playbook → verify — this is the production pattern for IaC deployments at companies using both Terraform and Ansible.
- The wait-for-SSM step is critical — EC2 instances take 60-120 seconds after launch to register with SSM; without the wait, Ansible fails to connect to instances that are not yet ready.
- Tags are the handoff mechanism: Terraform writes tags during provisioning, Ansible reads them via the dynamic EC2 inventory plugin — this decouples the two tools while maintaining correct group membership.
- The idempotency check at the end of the pipeline validates the entire role: running the playbook twice on configured instances must produce zero changes.
- Always destroy test infrastructure after the lab — EC2 instances and EIPs accumulate real costs; the orchestration script should always include a cleanup step, ideally as a shell trap that runs even if the pipeline fails.