
Part 2 walked through deploying the KloudGov application to a single EC2 instance entirely by hand, downloading an archive, installing packages, hand-writing a systemd unit, configuring Nginx, one SSH session at a time. It worked, but it was exactly as fragile as advertised. Every step depended on a human doing it correctly, in order, and none of it would scale past a handful of servers without becoming a genuine liability.
This post is the payoff. Terraform provisions the infrastructure networks, instances, and IAM roles. Ansible steps in immediately after, installing and configuring the application with precision and repeatability. Together, they replace the entire manual sequence from Part 2 with something that runs identically whether you’re deploying to one state or fifty.
This integration gets you:
Identical, versionable environments — every state gets the same configuration, driven by the same role.
A drastic reduction in manual errors — no more hand-typed systemd units with a typo in a bucket name.
Real scalability with less effort — adding a new state means adding one line to a variable, not repeating twenty manual steps.
More time for actual engineering work — less time re-running the same SSH commands, more time improving the system itself.
Step 1: Provisioning Infrastructure with Terraform’s Dynamic Inventory
Ansible needs an inventory: a list of hosts to manage, along with the connection details and variables each host needs. Manually maintaining that list (adding an entry every time a new EC2 instance comes up, removing one every time it’s destroyed) is exactly the kind of manual toil this series has been trying to eliminate. The fix: use Terraform’s provisioners to write directly to Ansible’s inventory file the moment an instance is created and clean up that same entry the moment it’s destroyed.
Add the following to the aws_instance resource in modules/aws_kloudgov_infrastructure/main.tf:
resource "aws_instance" "state_ec2" {
ami = "ami-007855ac798b5175e"
instance_type = "t3.micro"
key_name = "kloudbytegov-ec2-key"
vpc_security_group_ids = [aws_security_group.state_ec2_sg.id]
iam_instance_profile = aws_iam_instance_profile.s3_dynamodb_full_access_instance_profile.name
# Wait 30 seconds, then add the instance's SSH key to known_hosts
provisioner "local-exec" {
command = "sleep 30; ssh-keyscan ${self.private_ip} >> ~/.ssh/known_hosts"
}
# Append this instance's connection info to Ansible's default inventory file
provisioner "local-exec" {
command = "echo ${var.state_name} id=${self.id} ansible_host=${self.private_ip} ansible_user=ubuntu us_state=${var.state_name} aws_region=${var.region} aws_s3_bucket=${aws_s3_bucket.state_s3.bucket} aws_dynamodb_table=${aws_dynamodb_table.state_dynamodb.name} >> /etc/ansible/hosts"
}
# Remove this instance's entry from the inventory file when destroyed
provisioner "local-exec" {
command = "sed -i '/${self.id}/d' /etc/ansible/hosts"
when = destroy
}
tags = {
Name = "kloudgov-${var.state_name}"
}
}
Breaking down what each piece is actually doing:
The ssh-keyscan provisioner waits 30 seconds for the instance to finish booting, then automatically adds its SSH host key to known_hosts, avoiding the interactive “are you sure you want to continue connecting?” prompt that would otherwise block Ansible’s very first connection to a brand-new instance.
The inventory-append provisioner writes one line per instance to /etc/ansible/hosts Ansible’s default inventory location, including everything Ansible will need later: the host alias (${var.state_name}, e.g., michigan), the connection details (ansible_host, ansible_user), and application-specific variables (us_state, aws_s3_bucket, aws_dynamodb_table) that get passed straight into the Ansible role and its templates.
The when = destroy provisioner is the cleanup half of this pattern. When the instance is destroyed, this sed command finds the line containing that instance’s ID. It deletes it, so the inventory file never accumulates stale entries pointing at instances that no longer exist.
A Note on Where This Pattern Fits and Where It Doesn’t
It’s worth being direct about a real limitation here. This approach only works because terraform apply is being run from a persistent machine, your EC2 IDE instance, where /etc/ansible/hosts and ~/.ssh/known_hosts live on disk between runs. If you tried to run this same configuration through the GitHub Actions CI/CD pipeline built in the earlier infrastructure series, it would break immediately: local-exec runs on whatever machine executes terraform apply, and a GitHub-hosted runner is a fresh, disposable environment for every single job; there’s no persistent inventory file to append to, and no accumulated known_hosts file to reuse. For this project, running Terraform and Ansible from the same long-lived EC2 IDE instance is a reasonable, practical choice. A production setup wanting this same dynamic-inventory behavior inside a CI/CD pipeline would typically reach for Ansible’s aws_ec2 dynamic inventory plugin instead, which queries AWS directly at runtime rather than depending on file provisioners maintained over time.
Adding the Region Variable
The provisioner above references var.region, which doesn’t exist yet. Add it alongside the existing state_name variable:
# modules/aws_kloudgov_infrastructure/variables.tf
variable "state_name" {
description = "The name of the US State"
}
variable "region" {
default = "us-east-1"
}
Step 2: Creating an Empty Inventory File
Before the first terraform apply can append anything, the inventory file and the directory it lives in need to exist, with permissions that allow it to be written to by whichever user runs Terraform:
ls /etc/ansible sudo mkdir /etc/ansible sudo touch /etc/ansible/hosts # Required so Terraform's local-exec provisioners can write to this file sudo chown -R ec2-user:ec2-user /etc/ansible sudo chown ec2-user:ec2-user /etc/ansible/hosts
That ownership change matters more than it looks: local-exec runs as whatever user is executing terraform apply (here, ec2-user, the default user on the Amazon Linux EC2 IDE instance), and without write access to /etc/ansible/hosts, every apply would fail partway through with a permission error on the inventory-append step.
Step 3: Provisioning the Infrastructure
With everything in place, apply the updated configuration:
cd kloud-gov-infrastructure/terraform ls terraform plan terraform apply -auto-approve # Confirm the dynamic inventory populated correctly cat /etc/ansible/hosts
If the provisioners worked as expected, /etc/ansible/hosts now contains a single line describing the Michigan instance, its ID, private IP, SSH user, and the application-specific variables Ansible will need shortly.

Step 4: Committing Changes to the Local Repository
git status git add . git status git commit -m "Added variable and provisioners to Terraform module aws_kloudgov_infrastructure/main.tf"
Infrastructure is now fully deployed, with its own connection details automatically registered in Ansible’s inventory; it’s time to move to the configuration side of this pipeline.
Step 5: Creating an Ansible Role
Ansible organizes reusable configuration logic into roles, self-contained directory structures that cover tasks, variables, templates, and handlers for a specific configuration. This project’s role, kloudgov_webapp, will directly replace every manual step from Part 2.
Build the role’s directory structure:
cd ~/kloud-gov-infrastructure ls mkdir ansible && cd ansible ls mkdir -p roles/kloudgov_webapp/tasks mkdir -p roles/kloudgov_webapp/handlers mkdir -p roles/kloudgov_webapp/templates mkdir -p roles/kloudgov_webapp/defaults mkdir -p roles/kloudgov_webapp/vars mkdir -p roles/kloudgov_webapp/files ls roles/ ls roles/kloudgov_webapp/ sudo dnf install tree -y tree

Note the package manager here is dnf, not apt; that’s because this command runs on the Amazon Linux EC2 IDE instance (the Ansible control node), not on the Ubuntu-based instances Ansible will be managing. It’s an easy detail to gloss over, but worth keeping straight: control node commands and managed node commands live in two entirely different contexts throughout this post.
Step 6: Creating the Ansible Configuration File
pwd # /home/ec2-user/kloud-gov-infrastructure/ansible touch ansible.cfg
Add the following to suppress deprecation warning noise in the output:
[defaults] deprecation_warnings = False
Step 7: Testing Connectivity with Ansible’s Ping Module
Before writing a single task, confirm Ansible can actually reach the host registered in the inventory:
ansible all -m ping -e "ansible_ssh_private_key_file=/home/ec2-user/kloudbytegov-ec2-key.pem"
A successful ping response here confirms three things at once: the inventory file is correctly formatted, the SSH key works, and the ssh-keyscan provisioner from Step 1 did its job, with no interactive host-key prompt blocking the connection.

Step 8: Creating the Role’s Files
pwd # /home/ec2-user/kloud-gov-infrastructure/ansible touch roles/kloudgov_webapp/tasks/main.yml touch roles/kloudgov_webapp/handlers/main.yml touch roles/kloudgov_webapp/templates/nginx.conf.j2 touch roles/kloudgov_webapp/templates/kloudgov.service.j2 touch roles/kloudgov_webapp/defaults/main.yml touch roles/kloudgov_webapp/vars/main.yml touch deploy-kloudgov.yml tree
VS Code tip: to quickly navigate a directory tree this size, Ctrl+K then Ctrl+0 collapses everything (Cmd+K, Cmd+0 on Mac); Ctrl+K then Ctrl+J expands it all back out (Cmd+K, Cmd+J on Mac). Keys pressed in sequence, not together.
Step 9: Setting Default Role Variables
defaults/main.yml:
username: ubuntu
project_name: kloudgov
project_path: "/home/{{ username }}/{{ project_name }}"
source_application_path: /home/ec2-user/kloud-gov-application/src
source_application_path points back at the local src directory on the EC2 IDE machine, the same location the application zip was downloaded and extracted to back in Part 2. Since Ansible runs from this same machine, it can reference that path directly when copying the application archive out to each managed node.
Step 10: Configuring the Role’s Handlers
Handlers are actions triggered by a notify directive elsewhere in a role; they run only when something actually changes, and only once, even if multiple tasks notify the same handler.
handlers/main.yml:
- name: Restart Nginx
systemd:
name: nginx
state: restarted
become: yes
- name: Restart kloudgov
systemd:
name: kloudgov
state: restarted
become: yes
Step 11: Creating the Role’s Tasks
This is the heart of the automation: every single manual step from Part 2, translated into a declarative Ansible task. Laying them side by side against Part 2 makes the payoff concrete:
| Part 2 (Manual) | Part 3 (Ansible Task) |
|---|---|
| sudo apt update && sudo apt upgrade -y | apt: upgrade: dist |
| sudo apt-get install -y nginx python3-pip … | apt: name: […] state: present |
| sudo ufw allow ‘Nginx HTTP’ | ufw: rule: allow name: ‘Nginx HTTP’ |
| Manually mkdir, chown, chmod the project directory | file: state: directory owner: … mode: ‘0755’ |
| python3 -m venv $project_path/kloudgovenv | command: creates: (idempotent venv creation) |
| scp the zip, then ssh back in | copy: module, driven from the control node |
| unzip $project_path/kloudgov-app.zip | unarchive: remote_src: yes |
| pip install -r requirements.txt inside the venv | pip: requirements: virtualenv: |
| Hand-writing the systemd unit with tee | template: rendering a Jinja2 file |
| sudo systemctl enable/start kloudgov | systemd: enabled: yes state: started |
| sudo rm /etc/nginx/sites-enabled/default | file: state: absent |
| Hand-writing the Nginx config with tee | template: rendering a Jinja2 file |
| sudo ln -s … sites-enabled/ | file: state: link |
Here’s the full task file, tasks/main.yml:
- name: Update and upgrade apt packages
apt:
upgrade: dist
update_cache: yes
become: yes
- name: Install required packages
apt:
name:
- nginx
- python3-pip
- python3-dev
- build-essential
- libssl-dev
- libffi-dev
- python3-setuptools
- python3-venv
- unzip
state: present
become: yes
- name: Ensure UFW allows Nginx HTTP traffic
ufw:
rule: allow
name: 'Nginx HTTP'
become: yes
- name: Create project directory
file:
path: "{{ project_path }}"
state: directory
owner: "{{ username }}"
group: "{{ username }}"
mode: '0755'
become: yes
- name: Create Python virtual environment
command:
cmd: python3 -m venv {{ project_path }}/kloudgovenv
creates: "{{ project_path }}/kloudgovenv"
- name: Copy the application zip file to the destination
copy:
src: "{{ source_application_path }}/kloudgov-app.zip"
dest: "{{ project_path }}"
owner: "{{ username }}"
group: "{{ username }}"
mode: '0644'
become: yes
- name: Unzip the application zip file
unarchive:
src: "{{ project_path }}/kloudgov-app.zip"
dest: "{{ project_path }}"
remote_src: yes
notify: Restart kloudgov
become: yes
- name: Install Python packages from requirements.txt into the virtual environment
pip:
requirements: "{{ project_path }}/requirements.txt"
virtualenv: "{{ project_path }}/kloudgovenv"
- name: Create systemd service file for Gunicorn
template:
src: kloudgov.service.j2
dest: /etc/systemd/system/{{ project_name }}.service
notify: Restart kloudgov
become: yes
- name: Enable and start Gunicorn service
systemd:
name: "{{ project_name }}"
enabled: yes
state: started
become: yes
- name: Remove the default nginx configuration file
file:
path: /etc/nginx/sites-enabled/default
state: absent
become: yes
- name: Change permissions of the user's home directory
file:
path: "/home/{{ username }}"
mode: '0755'
become: yes
- name: Configure Nginx to proxy requests
template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/{{ project_name }}
become: yes
- name: Enable Nginx configuration
file:
src: /etc/nginx/sites-available/{{ project_name }}
dest: /etc/nginx/sites-enabled/{{ project_name }}
state: link
notify: Restart Nginx
become: yes
A design detail worth calling out: notify handlers only fire when the task that triggers them actually reports a change. If you run this playbook a second time and nothing’s different, the “Unzip” and “Create systemd service” tasks won’t report a change, so Restart kloudgov fires no unnecessary service restart on a system that’s already correctly configured. That’s the idempotency this whole design is building toward, and it becomes very visible in Step 18.
Step 12: The Gunicorn systemd Jinja2 Template
templates/kloudgov.service.j2:
[Unit]
Description=Gunicorn instance to serve {{ project_name }}
After=network.target
[Service]
User={{ username }}
Group=www-data
WorkingDirectory={{ project_path }}
Environment="US_STATE={{ us_state }}"
Environment="PATH={{ project_path }}/kloudgovenv/bin"
Environment="AWS_REGION={{ aws_region }}"
Environment="AWS_DYNAMODB_TABLE={{ aws_dynamodb_table }}"
Environment="AWS_BUCKET={{ aws_s3_bucket }}"
ExecStart={{ project_path }}/kloudgovenv/bin/gunicorn --workers 1 --bind unix:{{ project_path }}/{{ project_name }}.sock -m 007 {{ project_name }}:app
[Install]
WantedBy=multi-user.target
This is the single biggest improvement over Part 2. Back then, AWS_DYNAMODB_TABLE, AWS_BUCKET, and US_STATE were hardcoded by hand into a heredoc, specific to Michigan, with zero protection against a typo. Here, those same values are Jinja2 variables {{ us_state }}, {{ aws_s3_bucket }}, {{ aws_dynamodb_table }} populated automatically from the exact values Terraform wrote into the inventory file back in Step 1. There’s no manual editing per state, and no possibility of accidentally pointing one state’s Gunicorn service at another state’s S3 bucket.
Step 13: The Nginx Jinja2 Template
templates/nginx.conf.j2:
server {
listen 80;
server_name kloudgov www.kloudgov;
location / {
include proxy_params;
proxy_pass http://unix:{{ project_path }}/{{ project_name }}.sock;
}
}
Step 14: The Playbook
deploy-kloudgov.yml:
- hosts: all
roles:
- kloudgov_webapp
That’s the entire playbook. All the actual logic lives in the role; the playbook itself is just a pointer saying “apply this role to every host in the inventory.”
Step 15: Running the Playbook
pwd # /home/ec2-user/kloud-gov-infrastructure/ansible ls ansible-playbook deploy-kloudgov.yml -e "ansible_ssh_private_key_file=/home/ec2-user/kloudbytegov-ec2-key.pem"
Every task from Part 2 installing packages, configuring the firewall, creating the project directory, deploying the application, configuring systemd and Nginx now runs automatically, against every host in the inventory, in a single command.
Step 16: Testing the Deployment
Access the application via the EC2 instance’s public DNS name over HTTP, exactly as in Part 2, except this time, nothing was typed by hand on the target instance itself.

Step 17: Scaling to Two More States
This is where the real value of this whole pipeline becomes obvious. Adding infrastructure for two additional states, Florida and Nevada, is a single-line change:
# terraform/variables.tf
variable "states" {
description = "The list of state names"
default = ["michigan", "florida", "nevada"]
}
Apply it:
cd ../terraform/ pwd # /home/ec2-user/kloud-gov-infrastructure/terraform terraform plan # Plan: 18 to add terraform apply -auto-approve # Confirm the inventory now has all three hosts cat /etc/ansible/hosts # Confirm Ansible can reach all three ansible all -m ping -e "ansible_ssh_private_key_file=/home/ec2-user/kloudbytegov-ec2-key.pem"
Worth pausing on that plan output: 18 resources to add, not 27. Each state’s module produces 9 resources (security group, EC2 instance, DynamoDB table, S3 bucket, random string, IAM role, two policy attachments, instance profile), and Michigan’s 9 already exist in the state, so this plan reflects exactly the 2 new states (Florida and Nevada) at 9 resources each. If that math doesn’t check out when you run this yourself, it’s worth stopping to investigate before applying it; it usually means the state file and the actual states list have drifted apart somehow.
Step 18: Re-Running the Playbook Watching Idempotency Work
cd ../ansible/ pwd # /home/ec2-user/kloud-gov-infrastructure/ansible ansible-playbook deploy-kloudgov.yml -e "ansible_ssh_private_key_file=/home/ec2-user/kloudbytegov-ec2-key.pem"
Watch the output closely here. No action is taken for Michigan. Every task Ansible runs against the Michigan host reports “ok” rather than “changed” because Michigan is already in the exact state this role describes. Florida and Nevada, being brand new, go through the full configuration sequence.

This is idempotency, and it’s one of the most important properties separating configuration management from a plain shell script. A shell script run twice re-executes every command regardless of current state, reinstalling packages, rewriting files, restarting services that don’t need restarting. Ansible’s modules check current state first, and only make a change (and only fire a notified handler) when something actually needs to change. That’s what makes it safe to run this same playbook against Michigan again next month, or next year, without worrying it’ll disrupt a server that’s already correctly configured.
Step 19: Testing All Three States
cd ../terraform/ pwd # /home/ec2-user/kloud-gov-infrastructure/terraform terraform output
Use the output values to grab each instance’s public DNS, and confirm the application loads correctly in all three states.
Then test tenant isolation directly: add a new employee record specifically in the Nevada application instance, and check the AWS Console only Nevada’s DynamoDB table and S3 bucket receive that new data. Michigan and Florida’s resources are completely untouched, confirming each state’s infrastructure and data genuinely operate as an isolated tenant, exactly as the module design from the original infrastructure series intended.

Step 20: Committing and Synchronizing with GitHub
If you’re using AWS Cloud9: re-enable temporary credentials before pushing, since Cloud9’s built-in credential handling can interfere with Git operations against GitHub. Under Settings → AWS Settings → Credentials, turn off “AWS managed temporary credentials.”
Commit and push the infrastructure repository:
cd ~/kloud-gov-infrastructure git status git add . git status git commit -m "Initial Ansible configuration with updates to variables.tf (added Nevada and Florida states)." git push -u origin
And synchronize the application repository, which was already committed back in Part 2:
cd ~/kloud-gov-application git status # Should show a clean working tree git push # Confirms whether remote has commits local doesn't ls git pull --rebase # Updates local branch with any remote changes ls git push -u origin
git pull –rebase here does something specific worth understanding: rather than creating a merge commit, it replays your local commits on top of whatever’s newly on the remote, keeping a clean, linear commit history rather than a tangle of merge commits.
Step 21: Destroying the Resources
cd ~/kloud-gov-infrastructure/terraform terraform destroy -auto-approve
If you hit a “bucket not empty” error here, this is the intentional bug from Part 2 resurfacing: recall that deleting an employee through the application removes the DynamoDB record but leaves the corresponding file behind in S3. If you added and removed test employees during this post’s testing, those orphaned files are exactly what’s now blocking terraform destroy. S3 won’t let Terraform delete a bucket that still contains objects. Manually empty the affected bucket’s objects in the AWS Console, then re-run terraform destroy.
Close the remote connection and stop your EC2/IDE instance once everything is torn down.
What This Actually Changed
Every single manual step from Part 2 the SSH sessions, the hand-typed systemd unit, the risk of a Michigan-specific value accidentally ending up in a Nevada deployment is gone, replaced by one role and one playbook that behave identically regardless of which state, or how many states, you’re deploying to. Scaling from one state to three took a one-line variable change and a single command. Scaling to fifty would take the same amount of effort.
Key Takeaways
Terraform provisioners can dynamically maintain Ansible’s inventory file, but this pattern depends on running Terraform from a persistent machine; it doesn’t translate directly into an ephemeral CI/CD runner without switching to a proper dynamic inventory plugin.
Every manual step from a “do it by hand” deployment maps cleanly onto an Ansible module: apt, file, unarchive, pip, template, and systemd cover the entire Part 2 workflow between them.
Jinja2 templates eliminate the exact class of error that hardcoded, hand-typed config files invite: no more risk of one state’s bucket name leaking into another state’s service definition.
Idempotency is what makes configuration management fundamentally different from a shell script: re-running the same playbook against an already-correct server changes nothing, safely.
Scaling infrastructure and configuration together, through a single variable change plus a single playbook run, is the entire point of pairing Terraform with Ansible; the effort to add a fourth or fortieth state is identical to adding the second.
With provisioning, deployment, and configuration now fully automated end to end, the next natural step for this project is making these applications reachable through something better than a raw IP and HTTP an Application Load Balancer, ACM-issued TLS certificates, and Route 53 DNS, so each state gets a clean, secure, stable endpoint instead of a public DNS name that changes every time an instance is recreated. That’s coming in Part 4.

