Infrastructure as Code (IaC) and Configuration Management play a crucial role in modern DevOps workflows. In this post, I’ll explain how I automated the provisioning and configuration of EC2 instances using Terraform and Ansible. This approach not only improves deployment efficiency but also guarantees consistency across environments.

🙋🏼‍♀ Push-Based Ansible Configuration with Terraform

In this case, I structured my Terraform root module to launch multiple EC2 instances and used Ansible to configure them. Here’s the workflow:

  • Provisioning EC2 Instances with Terraform

I wrote a Terraform root module to deploy multiple EC2 instances. This module included essential resources such as security groups, key pairs, and instance configurations.

  • Configuring Nginx with Ansible

Once the instances were provisioned, I used Ansible playbooks to configure Nginx and deploy a sample configuration that serves a simple “Untested code is broken code!” page.😎

  • Inventory File (inventory.yaml): Defined the list of EC2 hosts.
webservers:
  hosts:
    ec2-xx-xx-xx-xx.eu-central-1.compute.amazonaws.com:
      host: web-0
    ec2-yy-yy-yy-yy.eu-central-1.compute.amazonaws.com:
      host: web-1
    ec2-zz-zz-zz-zz.eu-central-1.compute.amazonaws.com:
      host: web-2
    ec2-aa-aa-aa-aa.eu-central-1.compute.amazonaws.com:
      host: web-3
    ec2-bb-bb-bb-bb.eu-central-1.compute.amazonaws.com:
      host: web-4
    ec2-cc-cc-cc-cc.eu-central-1.compute.amazonaws.com:
  • Ansible Playbook (nginx.yaml): Installed and configured Nginx.
- name: Install nginx
  hosts: localhost
  remote_user: ec2-user
  become: yes

  tasks:
  - name: Install nginx on Al 2023
    ansible.builtin.package:
      name: "nginx"
      state: latest
  - name: Ensure nginx is running
    ansible.builtin.service:
      name: "nginx"
      #state: started
      enabled: yes
  - name: Copy nginx configuration file
    ansible.builtin.template:
      src: conf/nginx.conf.j2
      dest: /etc/nginx/conf.d/web.conf
    notify: Restart nginx
  handlers:
  - name: Restart nginx
    ansible.builtin.service:
      name: "nginx"
      state: restarted
  • Configuration Template (nginx.conf.j2): Used a Jinja2 template to define the Nginx configuration.
 server {
  listen 80;

  location / {
    default_type text/plain;
    return 200 "Untested code is broken code! {{ vars["host"] }}\n";
  }
}
 

To apply these configurations, I ran the Ansible playbooks manually (push-based logic):

ansible-playbook -i inventory.yaml nginx.yaml

🚀Modularizing Terraform with Child Modules

To improve maintainability, I created a child module to manage EC2 instances and related resources such as security groups and key pairs. The existing EC2 instances were moved from the root module into this new child module using Terraform’s moved statements, ensuring a smooth transition without resource recreation.

🙋🏼‍♀️ Pull-Based Ansible Configuration with Terraform User Data

In this case, I leveraged Terraform user data to enable a pull-based Ansible configuration. This approach allows instances to self-configure upon launch by fetching the necessary playbooks from a Git repository.

  • Using user-data to Run Ansible in Pull Mode

Instead of pushing configurations manually, I used Terraform’s user_data to execute an Ansible pull command at instance startup. Since user-data requires sensitive credentials (e.g., GitHub PAT — check out my related blog post, I stored it as a Terraform variable (sensitive = true) to prevent accidental exposure in logs.

The EC2 instance pulls the latest Ansible playbooks from a Git repository and applies them to itself:

🚀Here’s how the user-data section is structured within the Terraform configuration:

resource "aws_instance" "this" {
  for_each = { for _, v in range(var.instance_count) : "${var.name_prefix}-${v}" => null }

  ami           = var.ami_id
  instance_type = var.instance_type
  subnet_id     = var.subnet_id
  key_name      = aws_key_pair.this.key_name
  vpc_security_group_ids = [
    aws_security_group.this.id
  ]
  user_data = templatefile("${path.module}/files/cloud-init.template", {
    public_key = var.public_key
    pat        = var.pat
    host       = each.key
  })

  tags = {
    Name = each.key
  }
}
 

The user_data script references a Cloud-Init template (cloud-init.template) that prepares the instance by configuring SSH access with the provided public key, updating and upgrading packages, installing Ansible, defining a local Ansible inventory file, and executing ansible-pull to fetch and apply configuration playbooks from a Git repository.

#cloud-config
ssh_authorized_keys:
- ${public_key}
manage_etc_hosts: true
package_update: true
package_upgrade: true
packages:
- ansible
write_files:
 - encoding: raw
   path: /etc/ansible/hosts.yaml
   owner: root:root
   permissions: '0644'
   content: |
     ---
     webservers:
       hosts:
         127.0.0.1:
           ansible_connection=local
runcmd:
- export TOKEN=${pat}; ansible-pull -U https://tanrikuluozlem:$TOKEN@github.com/tanrikuluozlem/ansible-web --extra-vars host=${host} -d /projectFolder/ansible-web /projectFolder/ansible-web/ansible/nginx.yaml

🧑🏻‍🚀 By structuring Terraform modularly and leveraging Ansible for configuration management, I automated deployments without disrupting production. The move to Ansible pull mode further automated the process, ensuring ongoing consistency across the infrastructure.

This approach aligns with DevOps best practices — automated, repeatable, and scalable infrastructure management. Looking to build automated, scalable solutions? Let’s connect! 🙌🏻