Ansible
Ansible is an open-source automation tool that configures servers, deploys applications, and orchestrates multi-tier rollouts using human-readable YAML files called playbooks. Unlike Terraform, whose home turf is provisioning immutable cloud infrastructure, Ansible’s strength is configuration management — installing packages, editing files, managing services, and shaping the inside of a machine after it exists. It is agentless and push-based: it connects over plain SSH (or WinRM on Windows) from a control node and runs tasks remotely, so there is no daemon to install or maintain on managed hosts.
How Ansible works
Ansible runs from a control node — usually your laptop or a CI runner — against a set of managed nodes defined in an inventory. When you run a playbook, Ansible copies small task programs (modules) to each target over SSH, executes them with the host’s Python (or PowerShell), collects the results, and removes the temporary code. Because nothing is installed on the targets, onboarding a new host is just adding its address to the inventory and ensuring SSH access.
The core unit of work is a module. Each module is designed to be idempotent: running it repeatedly converges the system toward a declared state rather than blindly repeating an action. Asking for state: present on a package installs it only if it is missing; running the same playbook again reports ok instead of changed. This is the same desired-state philosophy that underpins Terraform, applied to the contents of a server instead of the cloud resources around it.
A small example
An inventory lists the hosts, grouped logically:
[web]
web1.example.com
web2.example.com
[web:vars]
ansible_user=ubuntu
A playbook then declares the desired state of those hosts. This one installs and starts Nginx and drops in a templated config file:
- name: Configure web servers
hosts: web
become: true
vars:
worker_processes: 4
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy nginx config
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
mode: "0644"
notify: Restart nginx
- name: Ensure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
Run it with the CLI:
ansible-playbook -i inventory.ini site.yml
Output:
PLAY [Configure web servers] ***************************************************
TASK [Install nginx] ***********************************************************
changed: [web1.example.com]
changed: [web2.example.com]
TASK [Deploy nginx config] *****************************************************
changed: [web1.example.com]
changed: [web2.example.com]
TASK [Ensure nginx is running and enabled] *************************************
ok: [web1.example.com]
ok: [web2.example.com]
RUNNING HANDLER [Restart nginx] ***********************************************
changed: [web1.example.com]
changed: [web2.example.com]
PLAY RECAP ********************************************************************
web1.example.com : ok=4 changed=3 unreachable=0 failed=0
web2.example.com : ok=4 changed=3 unreachable=0 failed=0
Run the same command again and the changed counts drop to zero — the convergence is idempotent.
Roles and handlers
As playbooks grow, you factor reusable units into roles — a standard directory layout (tasks/, templates/, handlers/, defaults/, vars/) that packages everything needed to configure one piece of a system. Roles are Ansible’s equivalent of Terraform modules and can be shared through Ansible Galaxy. Handlers are tasks that run only when notified by a changed task (as the Restart nginx handler above shows), so a service restarts exactly once per run no matter how many config files changed.
Ansible and Terraform together
Ansible and Terraform are complementary rather than competitors. The common pattern is: provision with Terraform, configure with Ansible. Terraform creates the VPC, security groups, and EC2 instances; Ansible then logs into those instances and installs the software stack. You bridge the two by having Terraform emit an inventory the Ansible run can consume:
resource "aws_instance" "web" {
count = 2
ami = "ami-0c7217cdde317cfec"
instance_type = "t3.micro"
tags = { Name = "web-${count.index}" }
}
resource "local_file" "ansible_inventory" {
filename = "${path.module}/inventory.ini"
content = "[web]\n${join("\n", aws_instance.web[*].public_ip)}\n"
}
After terraform apply writes the inventory, the ansible-playbook command above takes over. This division of labour keeps each tool in its sweet spot. For dynamic inventories you can skip the file entirely and use the amazon.aws.aws_ec2 inventory plugin, which queries instances by tag at runtime.
Ansible is procedural at heart — tasks execute top to bottom, and idempotency depends on each module being written correctly. It does not build a dependency graph or track state the way Terraform does, so prefer Terraform for managing the lifecycle of cloud resources and reach for Ansible’s
cloudmodules only for glue or one-off actions.
How it compares
| Dimension | Terraform / OpenTofu | Ansible |
|---|---|---|
| Primary job | Provision infrastructure | Configure & manage existing systems |
| Model | Declarative, desired-state graph | Mostly procedural, idempotent tasks |
| State | Tracked in a state file | Stateless; queries each host live |
| Transport | Provider APIs | Agentless over SSH / WinRM |
| Language | HCL | YAML playbooks + Jinja2 templates |
| Strong fit | Cloud resources, immutable infra | OS config, app deploy, orchestration |
Best Practices
- Keep playbooks idempotent: use modules with explicit
staterather thancommand/shell, and usecreates/changed_whenwhen you must shell out. - Organise reusable logic into roles with sensible
defaults/so behaviour is overridable per environment. - Encrypt secrets with Ansible Vault (
ansible-vault encrypt) and never commit plaintext credentials. - Pin collection and role versions in
requirements.ymlso runs are reproducible across machines and CI. - Use
--check --difffor a dry run before applying changes to production hosts. - Let Terraform own resource lifecycles and pass its outputs (or a dynamic inventory plugin) into Ansible rather than duplicating provisioning logic.