IaC Ecosystem

Ansible for Configuration Management

● Intermediate ⏱ 20 min read iac

Terraform provisions infrastructure — it creates VMs, networking, databases, and cloud resources. What it doesn't do well is configure what runs on those VMs after they exist: installing packages, writing config files, managing services, and deploying application code. Ansible fills that gap. Together, Terraform and Ansible cover the full lifecycle: Terraform provisions the machine, Ansible configures it.

What Is Ansible?

Ansible is an agentless automation tool written in Python. "Agentless" means it connects to target machines over SSH (or WinRM for Windows) and executes tasks directly — no daemon or agent process needs to be installed on managed nodes.

Key properties:

Ansible vs Terraform

TerraformAnsible
Primary useProvision cloud infrastructureConfigure servers, deploy apps
ModelDeclarative (desired state)Procedural (ordered tasks), idempotent
StateState file tracks resourcesNo persistent state — re-reads target on each run
TargetsCloud provider APIsServers via SSH
Config formatHCLYAML
LanguageTerraform/OpenTofuPython (runtime), YAML (playbooks)
Cloud provisioningExcellentPossible but awkward
Server configNot designed for itExcellent

They're complementary, not competing. The standard pattern is: Terraform creates the VM and outputs its IP; Ansible connects to that IP and configures it.

Inventory

Ansible needs to know which machines to manage. An inventory is a list of hosts, optionally grouped:

# inventory.ini — static inventory
[webservers]
web1.example.com
web2.example.com ansible_user=ubuntu

[databases]
db1.example.com ansible_port=2222

[production:children]
webservers
databases

[production:vars]
ansible_python_interpreter=/usr/bin/python3

Dynamic inventories generate the host list at runtime — useful when Terraform manages your servers and IP addresses change. The AWS dynamic inventory plugin reads EC2 tags:

# aws_ec2.yaml — dynamic inventory
plugin: amazon.aws.aws_ec2
regions:
  - us-east-1
filters:
  tag:Environment: production
  instance-state-name: running
keyed_groups:
  - key: tags.Role
    prefix: role
# Use it
ansible-inventory -i aws_ec2.yaml --list
ansible -i aws_ec2.yaml role_webserver -m ping

Playbooks

A playbook is a YAML file defining which tasks run on which hosts:

---
- name: Configure web servers
  hosts: webservers
  become: true            # sudo
  vars:
    app_port: 8080
    app_version: "2.1.0"

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Write nginx config
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/app
        mode: "0644"
      notify: Reload nginx

    - name: Enable site
      ansible.builtin.file:
        src: /etc/nginx/sites-available/app
        dest: /etc/nginx/sites-enabled/app
        state: link

    - name: Start nginx
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

Key playbook concepts:

Run a playbook:

ansible-playbook -i inventory.ini site.yml
ansible-playbook -i inventory.ini site.yml --check   # dry run
ansible-playbook -i inventory.ini site.yml --diff     # show file diffs
ansible-playbook -i inventory.ini site.yml --limit webservers  # subset of hosts

Roles

Roles are Ansible's reusability unit — a structured directory that bundles tasks, templates, variables, and handlers into a reusable component:

roles/
  nginx/
    tasks/
      main.yml        # tasks entry point
    templates/
      nginx.conf.j2   # Jinja2 templates
    handlers/
      main.yml        # handlers
    defaults/
      main.yml        # default variable values
    vars/
      main.yml        # role-internal variables (higher priority than defaults)
    meta/
      main.yml        # role dependencies

Use a role in a playbook:

---
- name: Configure production servers
  hosts: production
  become: true
  roles:
    - role: nginx
      vars:
        nginx_worker_processes: 4
    - role: app_deploy
      vars:
        app_version: "{{ lookup('env', 'APP_VERSION') }}"

Share and consume roles via Ansible Galaxy:

# requirements.yml
roles:
  - name: geerlingguy.docker
    version: 7.1.0
  - name: geerlingguy.nodejs
    version: 6.1.0

collections:
  - name: community.docker
    version: ">=3.0"
ansible-galaxy install -r requirements.yml

Variables & Secrets

Variable precedence in Ansible (higher overrides lower):

  1. Extra vars (-e flag) — highest
  2. Task vars
  3. Block vars
  4. Role and include vars
  5. Play vars
  6. Host vars
  7. Group vars
  8. Role defaults — lowest

Ansible Vault encrypts sensitive values at rest:

# Encrypt a file
ansible-vault encrypt group_vars/production/secrets.yml

# Reference encrypted vars normally in playbooks — Ansible decrypts at runtime
# Decrypt at runtime
ansible-playbook site.yml --vault-password-file ~/.vault_pass
ansible-playbook site.yml --ask-vault-pass
---
# group_vars/production/secrets.yml (encrypted with Vault)
db_password: "s3cr3t-production-password"
api_key: "sk-prod-xxxxxxxxxxxxxxxx"

Pairing with Terraform

The standard integration pattern: Terraform outputs the newly created server IPs, then Ansible runs against them.

Option 1: Shell script orchestration

# deploy.sh
set -e

# Provision infrastructure
cd infra/
terraform init
terraform apply -auto-approve

# Get outputs
WEB_IP=$(terraform output -raw web_server_ip)
DB_IP=$(terraform output -raw db_server_ip)

# Configure servers
cd ../ansible/
cat > inventory.ini <

Option 2: Terraform local-exec provisioner

resource "aws_instance" "web" {
  ami           = data.aws_ami.ubuntu.id
  instance_type = "t3.small"
  key_name      = aws_key_pair.deploy.key_name

  provisioner "local-exec" {
    command = <<-EOT
      sleep 30  # wait for SSH to be available
      ansible-playbook \
        -i '${self.public_ip},' \
        -u ubuntu \
        --private-key ${var.ssh_key_path} \
        ansible/site.yml
    EOT
  }
}
⚠️
Prefer external orchestration over provisioners

Terraform provisioners run only at resource creation time and are skipped on subsequent terraform apply runs. They also complicate the apply lifecycle — if the provisioner fails, Terraform marks the resource as tainted and will destroy/recreate it on the next run. Shell script or CI/CD orchestration is more predictable.

Option 3: Dynamic inventory from Terraform state

The terraform-inventory tool generates an Ansible inventory directly from Terraform state:

# Install terraform-inventory
go install github.com/adammck/terraform-inventory@latest

# Use it directly
ansible-playbook -i $(which terraform-inventory) site.yml

Key Takeaways