What You'll Build
This lab connects all four Ansible fundamentals lessons into a complete, real-world workflow. You will use Terraform to provision two EC2 instances with IAM instance profiles granting SSM access, run Ansible with the dynamic EC2 inventory plugin to discover those instances automatically by tag, and configure them using the baseline playbook via AWS SSM Session Manager — entirely without SSH keys, open port 22, or a bastion host. This SSM-based Ansible workflow is the modern production pattern: no credential distribution, no firewall exceptions, and every session logged to CloudWatch. The lab demonstrates the provision-then-configure handoff between Terraform and Ansible that forms the foundation of the complete IaC pipeline covered in Module 5 and the Capstone.
Prerequisites
- Terraform 1.6+ and Ansible 2.15+ installed on the control machine
- AWS CLI configured with permissions for EC2, IAM and SSM
- Python packages: boto3, botocore (for dynamic inventory), community.aws collection
- Session Manager Plugin for AWS CLI: 'curl -s https://s3.amazonaws.com/session-manager-downloads/plugin/latest/ubuntu_64bit/session-manager-plugin.deb -o /tmp/smp.deb && sudo dpkg -i /tmp/smp.deb'
Step 1 — Provision EC2 Instances with Terraform
# main.tf — Provision EC2 instances for Ansible SSM lab
mkdir -p ~/ansible_ssm_lab && cd ~/ansible_ssm_lab
cat > 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 sources
data "aws_ami" "al2023" {
most_recent = true
owners = ["amazon"]
filter { name = "name"; values = ["al2023-ami-*-x86_64"] }
filter { name = "state"; values = ["available"] }
filter { name = "architecture"; values = ["x86_64"] }
}
data "aws_vpc" "default" { default = true }
# IAM role for SSM access
resource "aws_iam_role" "ssm_instance" {
name = "cricket-lab-ssm-instance"
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.ssm_instance.name
policy_arn = "arn:aws:iam::aws:policy/AmazonSSMManagedInstanceCore"
}
resource "aws_iam_instance_profile" "ssm" {
name = "cricket-lab-ssm-profile"
role = aws_iam_role.ssm_instance.name
}
# Security group: outbound HTTPS only (for SSM), no inbound SSH
resource "aws_security_group" "lab" {
name = "cricket-lab-ssm-sg"
vpc_id = data.aws_vpc.default.id
egress {
description = "HTTPS for SSM and package downloads"
from_port = 443
to_port = 443
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
egress {
description = "HTTP for package downloads"
from_port = 80
to_port = 80
protocol = "tcp"
cidr_blocks = ["0.0.0.0/0"]
}
tags = { Name = "cricket-lab-ssm-sg" }
}
# Two EC2 instances tagged for dynamic inventory discovery
resource "aws_instance" "lab" {
count = 2
ami = data.aws_ami.al2023.id
instance_type = "t3.micro"
iam_instance_profile = aws_iam_instance_profile.ssm.name
vpc_security_group_ids = [aws_security_group.lab.id]
metadata_options {
http_endpoint = "enabled"
http_tokens = "required" # IMDSv2
http_put_response_hop_limit = 1
}
# CRITICAL: Tags are how the dynamic inventory discovers these instances
tags = {
Name = "cricket-lab-server-${count.index + 1}"
Project = "cricket-analytics" # Matched by aws_ec2.yml plugin filter
Environment = "development"
Role = "api-server" # Used for group membership in inventory
AnsibleLab = "true" # Easy filter for lab instances
}
}
output "instance_ids" { value = aws_instance.lab[*].id }
output "instance_ips" { value = aws_instance.lab[*].private_ip }
output "ssm_commands" {
value = [for i in aws_instance.lab : "aws ssm start-session --target ${i.id} --region ap-south-1"]
}
TF
terraform init && terraform apply -auto-approve
echo 'Instances launched — waiting 90s for SSM agent to register'
sleep 90Step 2 — Configure Dynamic Inventory
# inventory/aws_ec2.yml — Dynamic EC2 inventory for the lab
plugin: amazon.aws.ec2
regions:
- ap-south-1
# Only discover running instances tagged for this lab
filters:
instance-state-name: running
'tag:AnsibleLab': 'true'
'tag:Project': cricket-analytics
hostnames:
- instance-id # Use instance ID as hostname for SSM connectivity
keyed_groups:
- prefix: tag
key: tags
groups:
api_servers: "tags.Role == 'api-server'"
compose:
# CRITICAL: SSM connection uses instance ID, not IP address
ansible_host: instance_id
# Set the SSM connection plugin
ansible_connection: 'community.aws.aws_ssm'
ansible_aws_ssm_region: placement.region
# SSM connection does not need SSH user or key
ansible_user: ec2-userStep 3 — Verify Inventory and Connectivity
#!/bin/bash
cd ~/ansible_ssm_lab
# Install required collections
ansible-galaxy collection install amazon.aws community.aws community.general
echo '=== Step 3a: Verify dynamic inventory discovers both instances ==='
ansible-inventory -i inventory/aws_ec2.yml --graph
# Expected:
# @all:
# |--@api_servers:
# | |--i-0abc123...
# | |--i-0def456...
# |--@tag_Role_api_server:
# | ...
ansible-inventory -i inventory/aws_ec2.yml --list 2>/dev/null | \
python3 -c "import sys,json; inv=json.load(sys.stdin); print(f'Hosts: {list(inv[\"_meta\"][\"hostvars\"].keys())}')"
echo
echo '=== Step 3b: Test SSM connectivity ==='
# SSM ping: no SSH key or port 22 required
ansible api_servers \
-i inventory/aws_ec2.yml \
-m ansible.builtin.ping \
-e ansible_connection=community.aws.aws_ssm
# Expected: instance-id | SUCCESS => { 'ping': 'pong' }
echo
echo '=== Step 3c: Collect facts via SSM ==='
ansible api_servers \
-i inventory/aws_ec2.yml \
-m ansible.builtin.setup \
-a 'filter=ansible_distribution*' \
-e ansible_connection=community.aws.aws_ssm 2>/dev/null | \
grep -A5 'ansible_distribution'Step 4 — Run Baseline Playbook via SSM
# lab_playbook.yml — simplified baseline for the lab (Amazon Linux 2023)
---
- name: Configure Cricket Analytics lab servers via SSM
hosts: api_servers
become: true
gather_facts: true
handlers:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
tasks:
- name: Install packages (dnf for Amazon Linux)
ansible.builtin.dnf:
name:
- nginx
- chrony
- python3
- jq
state: present
tags: [packages]
- name: Configure Nginx — simple health endpoint
ansible.builtin.copy:
content: |
server {
listen 80;
location /health {
add_header Content-Type application/json;
return 200 '{"status":"ok","host":"{{ inventory_hostname }}","env":"lab"}';
}
}
dest: /etc/nginx/conf.d/cricket-health.conf
mode: '0644'
notify: Restart Nginx
tags: [nginx]
- name: Enable and start Nginx
ansible.builtin.service:
name: nginx
enabled: true
state: started
tags: [nginx]
- name: Configure chrony to use AWS Time Sync Service
ansible.builtin.lineinfile:
path: /etc/chrony.conf
regexp: '^pool '
line: 'server 169.254.169.123 prefer iburst'
state: present
notify: Restart chrony
tags: [chrony]
- name: Ensure chrony is enabled and running
ansible.builtin.service:
name: chronyd
enabled: true
state: started
tags: [chrony]
- name: Verify Nginx health endpoint
ansible.builtin.uri:
url: http://localhost/health
status_code: 200
register: health
tags: [verify]
- name: Show health check result
ansible.builtin.debug:
msg: 'Health: {{ health.json }}'
tags: [verify]
handlers:
- name: Restart chrony
ansible.builtin.service:
name: chronyd
state: restarted#!/bin/bash
cd ~/ansible_ssm_lab
echo '=== Step 4a: Dry run first ==='
ansible-playbook \
-i inventory/aws_ec2.yml \
lab_playbook.yml \
--check --diff
echo
echo '=== Step 4b: Apply playbook via SSM ==='
ansible-playbook \
-i inventory/aws_ec2.yml \
lab_playbook.yml \
-v # Verbose output
echo
echo '=== Step 4c: Verify idempotency ==='
ansible-playbook \
-i inventory/aws_ec2.yml \
lab_playbook.yml 2>&1 | grep 'PLAY RECAP' -A3
# All changed= should be 0 on second run
echo
echo '=== Step 4d: Check SSM Session Manager logs in CloudWatch ==='
# SSM logs every command executed via Session Manager
aws logs describe-log-groups \
--log-group-name-prefix '/aws/ssm' \
--query 'logGroups[*].logGroupName' \
--output text
echo
echo '=== Step 5: Cleanup — destroy Terraform resources ==='
terraform destroy -auto-approve
echo 'Lab resources destroyed'Pro Tip
Use 'serial: 1' and 'max_fail_percentage: 0' in the play definition for production playbook runs against multiple hosts. Serial: 1 runs the playbook against one host at a time — if any host fails, the play stops before the remaining hosts are affected. Max_fail_percentage: 0 means a single failure stops the entire play. This canary deployment pattern catches bad configurations early without applying them to all hosts simultaneously. For a rolling update, use 'serial: '25%'' to update one quarter of the fleet at a time, allowing manual verification between batches.
- SSM-based Ansible connectivity uses 'ansible_connection: community.aws.aws_ssm' and 'ansible_host: instance_id' — no SSH keys, no port 22, no bastion host required; IAM role provides the authentication mechanism.
- The dynamic EC2 inventory discovers instances by tags — any instance tagged 'Project=cricket-analytics' and 'Role=api-server' automatically appears in the correct Ansible groups without manual inventory updates.
- Tags on EC2 instances are the handoff point between Terraform (provisioning) and Ansible (configuration) — Terraform tags instances, Ansible discovers them; this decouples the two tools while maintaining correct group membership.
- Verify idempotency by running the playbook twice — the second run should show changed=0 for all tasks; non-zero changed= on re-run indicates a task that makes changes even when the system is already in the desired state.
- SSM Session Manager logs all command executions to CloudWatch — this provides the audit trail required for compliance (SOC 2, PCI-DSS) without separate bastion host logging infrastructure.
- Use '--check --diff' before any production playbook run — check mode shows what would change, --diff shows the exact content changes for file-modifying tasks; this is the Ansible equivalent of 'terraform plan'.