Terraform and Ansible Automation: Building the KloudGov Deployment Pipeline (Part 3)
Terraform-IAM-Role-For-EC2
ByOlaniyi Oladimeji
Terraform and Ansible Automation: Building the KloudGov Deployment Pipeline (Part 3)

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.

{{ reviewsTotal }}{{ options.labels.singularReviewCountLabel }}
{{ reviewsTotal }}{{ options.labels.pluralReviewCountLabel }}
{{ options.labels.noReviewsLabel }}
{{ options.labels.newReviewButton }}
{{ userData.canReview.message }}

Related Posts

EKS-KloudGov-Cluster
AWS EKS Cluster Setup: Provisioning Kubernetes with eksctl and Terraform (Part 1)
This new series adds a third architecture: Kubernetes, via Amazon EKS (Elastic Kubernetes Service), fronted...
Docker-and-ECR
Terraform ECS Automation: Fully Automated, HTTPS-Secured Fargate Services (Part 4)
Everything built by hand in Part 3 the task definition, the target group, the ALB,...
Docker-and-ECR
ECS Task Definitions and Fargate Services: Deploying KloudGov (Part 3)
creating the task definition through the console, launching the service, and watching it come up...