Ansible
Ansible is an agentless, SSH-based automation engine: a single control node reads an inventory of hosts, pushes small self-contained Python modules over an ordinary SSH connection, and runs idempotent YAML playbooks against them — no daemon to install on the fleet, no port to open, no certificate authority to stand up. It is Red Hat's answer to the second half of the problem this course's configuration management page named: once a server already exists, Ansible is how you make the software running on it match a version-controlled definition, and keep it there. This page covers the agentless push architecture in real detail, the inventory/playbook/role structure you actually write, what idempotency looks like task by task, the day-to-day CLI, the failure modes that show up once a fleet grows past a few dozen hosts, and exactly where Ansible's job ends and Terraform's begins — the two are run back to back in the same pipeline far more often than they're chosen instead of each other.
Imagine a substitute teacher who walks into any classroom with nothing but a checklist and a master key — no locker of her own, nothing left behind in the room while she's away. She checks the checklist against what's actually there: whiteboard clean, chairs in rows, reading corner stocked. She fixes only what's wrong and leaves everything else exactly alone, then locks up and moves to the next room. She didn't build the school — someone else poured the foundation and put the walls up long before she ever showed up. That's Ansible: nothing lives permanently in any room, just a checklist and a key, run whenever someone asks her to check.
What Ansible is and the problem it solves
☺ Like you're 10: It's a free tool that logs into your servers over ordinary SSH and makes sure the right software and settings are there — no permanent helper program has to live on the server first.
Ansible was released by Michael DeHaan in 2012 and acquired by Red Hat in October 2015. Today the open-source engine itself is packaged as ansible-core — a minimal install containing the runtime and a small set of built-in (ansible.builtin) modules — while the wider "Ansible" package many teams install bundles ansible-core with a curated set of community and partner collections on top. Red Hat's commercial layer above that, Ansible Automation Platform (AAP), adds a web UI, role-based access control, and centralized job scheduling; its direct open-source ancestor and continuing upstream is the AWX project, worth knowing about even if you never run it, because "Ansible with a web console" almost always means one of these two.
The defining trait, and the one that separates Ansible from most of the rest of the configuration management category, is agentless. Ansible is written in Python and playbooks are written in YAML, but nothing Ansible-specific needs to be installed or kept running on a managed host — only an SSH server and a Python interpreter, both of which are already present on virtually every Linux distribution by default. Contrast that with the pull-based, agent-resident model of Puppet and Chef, where a persistent local process phones home to a central server on its own schedule; that push-vs-pull framing is covered in full on configuration management, and this page goes one level deeper into how Ansible itself actually pulls the push model off.
The problem this solves is the second half of infrastructure work that provisioning alone never touches: once a server, VM, or container host exists, something still has to install the right packages, drop the right config files, create the right users, and keep the right services running — on forty hosts or four thousand — as a definition that lives in git and gets reviewed like any other change, rather than as tribal memory of what someone typed over SSH last Tuesday night.
Where Ansible fits: provisioning vs. configuration, and why it pairs with Terraform
☺ Like you're 10: Terraform is the construction crew that makes the building exist; Ansible is the crew that moves in afterward and sets everything up right — they hand the job to each other instead of arguing over who does it.
The boundary this course already draws on infrastructure as code and configuration management applies to Ansible with no exceptions: Terraform's job ends the moment terraform apply finishes creating a resource — an EC2 instance, a VM, a managed database — and records its identity in Terraform's state file. Ansible's job starts once that resource already exists: SSH in, install packages, template configuration, start services, and keep doing it every time the playbook runs again. In practice the two are frequently paired, not competing — most real pipelines run terraform apply as one stage and an Ansible playbook as the very next one, against whatever Terraform just created.
The pairing has a specific, well-worn shape. Terraform tags the instances it provisions; Ansible's dynamic inventory plugins — amazon.aws.aws_ec2, azure.azcollection.azure_rm, google.cloud.gcp_compute — query the cloud provider's API directly at run time and group hosts by exactly those tags, so nobody hand-maintains a static inventory file that goes stale the moment autoscaling changes the fleet.
# inventory/aws_ec2.yml — a dynamic inventory source, not a static hosts file
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
filters:
tag:ManagedBy: ansible
instance-state-name: running
keyed_groups:
- key: tags.Role # instances tagged Role=web land in group "role_web"
prefix: role
- key: tags.Environment # tags.Environment=prod -> group "env_prod"
prefix: env
compose:
ansible_host: public_ip_addressAnsible does ship modules that can create cloud infrastructure — the amazon.aws collection's ec2_instance, rds_instance, and dozens more — and a "create if it doesn't already exist" task is genuinely idempotent for that one creation step. What it structurally lacks is a state file, a plan/diff step, or any built-in notion that a resource no longer declared in a playbook should now be destroyed. Terraform tracks ownership of everything it created and can show you exactly what a change will add, modify, or destroy before it touches anything; re-running an Ansible provisioning task only ever answers "does this exist yet," never "should this still exist." That gap is exactly why teams reach for Terraform to own the provisioning layer and Ansible to own configuration, rather than picking one tool to do both end to end.
The tempting shortcut is a Terraform null_resource with a local-exec provisioner that shells out straight to ansible-playbook at the end of an apply. Resist it. Terraform's own documentation calls provisioners "a last resort" for exactly this reason: if the shelled-out playbook fails partway through, Terraform has no way to represent that — the resource it created is marked successfully applied regardless, and the actual configuration state is now silently unknown. Run the two tools as separate, sequential pipeline stages instead — terraform apply, then ansible-playbook -i inventory/aws_ec2.yml site.yml against a dynamic inventory — so each tool's own success/failure signal stays honest and visible in CI/CD.
Architecture: the agentless SSH push model
☺ Like you're 10: One control computer reaches out over ordinary SSH, drops off a tiny helper program, watches it run, then deletes it — nothing is left behind waiting on the server afterward.
A control node is any machine with Python and ansible-core installed — a laptop, a CI runner, a dedicated bastion host. It needs no special network configuration of its own: it simply needs outbound SSH reachability to whatever it manages. A managed node needs an SSH server (or WinRM, for Windows targets, which uses a different transport and a different privilege-escalation model entirely) and a Python interpreter — nothing else. Ansible auto-discovers which Python binary to use on each managed host via an interpreter-discovery mechanism (the modern default, auto_silent, tries several common paths and just picks one), though pinning it explicitly with the ansible_python_interpreter variable avoids a real gotcha covered below.
The same SSH connection is reused for every task in a play rather than renegotiated from scratch each time, via OpenSSH's ControlPersist — a meaningful part of why Ansible feels fast against a host it's already touched once in a run. Turning on pipelining = True in ansible.cfg goes a step further, streaming a module's Python code over stdin instead of writing and then executing a temp file, which avoids one SFTP round trip per task; it requires requiretty to be disabled in the managed host's sudoers file, which is the usual reason teams forget to turn it on. The single biggest lever over total run time at scale is forks — how many hosts Ansible works on simultaneously, five by default — bounded in practice by the control node's own CPU and memory and by however many concurrent SSH sessions the target hosts and any bastion in between can tolerate.
Configuration itself is read from an ansible.cfg file, and Ansible checks a fixed set of locations in order, using the first one it finds rather than merging them: the ANSIBLE_CONFIG environment variable, then ./ansible.cfg in the current directory, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg. A project-local ansible.cfg checked into the repository next to the playbooks it configures is the convention worth defaulting to.
# ansible.cfg — checked into the project root, next to site.yml [defaults] inventory = inventory/hosts.yml roles_path = roles forks = 20 host_key_checking = False retry_files_enabled = False [privilege_escalation] become = True become_method = sudo become_ask_pass = False # relies on NOPASSWD sudoers for the automation user — see the gotcha below [ssh_connection] pipelining = True ssh_args = -o ControlMaster=auto -o ControlPersist=60s
Inventory, playbooks, and roles — the structure you actually write
☺ Like you're 10: Three things: a list of which computers to talk to, a script describing what should be true on them, and a reusable folder of that script broken into shareable pieces.
An inventory is the list of managed hosts and the groups they belong to. It can be static — a plain INI or YAML file — or dynamic, generated at run time by a plugin like the aws_ec2 source shown above. Groups can nest inside other groups ([prod:children]), and group-level or host-level variables can live directly in the inventory file or, more commonly on real projects, in a group_vars/ or host_vars/ directory next to it — files there are loaded automatically based on group or hostname, with no -e flag required.
# inventory/hosts.ini — static INI format [web] web-01.internal ansible_host=10.0.1.11 web-02.internal ansible_host=10.0.1.12 [db] db-01.internal ansible_host=10.0.2.11 [prod:children] web db [web:vars] http_port=8080
# inventory/hosts.yml — the equivalent, static YAML format
all:
children:
web:
hosts:
web-01.internal: { ansible_host: 10.0.1.11 }
web-02.internal: { ansible_host: 10.0.1.12 }
vars:
http_port: 8080
db:
hosts:
db-01.internal: { ansible_host: 10.0.2.11 }A playbook is one or more YAML "plays," each targeting a host pattern from the inventory and listing the tasks to run against it, in order. A role is the packaging unit for reusable playbook content — the point at which a project stops being one long playbook and starts being composable pieces other playbooks (and other teams) can include. ansible-galaxy init nginx scaffolds the conventional layout below, and Ansible loads each subdirectory's main.yml automatically by name, with no explicit wiring required.
roles/
└── nginx/
├── tasks/main.yml # the task list itself — what this role actually does
├── handlers/main.yml # notify-triggered tasks (reload, restart)
├── templates/nginx.conf.j2 # Jinja2 templates, rendered per-host
├── files/robots.txt # static files copied verbatim
├── vars/main.yml # role-internal variables, high precedence
├── defaults/main.yml # role defaults, LOWEST precedence — meant to be overridden
├── meta/main.yml # role dependencies, Galaxy metadata
└── molecule/default/ # optional: Molecule test scenario for this role# site.yml — a playbook invoking the role above, overriding one default
- name: Configure web tier
hosts: web
become: true
roles:
- role: nginx
vars:
nginx_worker_connections: 2048Templates use Jinja2, the same templating language Ansible's parent ecosystem is built on, letting a single file render differently per host from inventory variables:
# templates/nginx.conf.j2
events {
worker_connections {{ nginx_worker_connections | default(1024) }};
}
http {
server {
listen {{ http_port | default(80) }};
{% for name in allowed_hosts %}
server_name {{ name }};
{% endfor %}
}
}Since Ansible 2.10, modules, roles, and plugins are distributed in versioned bundles called collections — amazon.aws, community.general, ansible.posix — installed from Ansible Galaxy and referenced by their fully-qualified collection name (ansible.builtin.copy, amazon.aws.ec2_instance). A project's exact collection and role versions are pinned in a requirements.yml file, the same role a Chart.lock plays for a Helm chart or a provider lock file plays for Terraform — commit it, and ansible-galaxy install -r requirements.yml reproduces the exact same content everywhere.
Idempotency in practice
☺ Like you're 10: A good task says "make sure nginx is installed," not "install nginx" — so running it a hundred times in a row does nothing after the first one.
Configuration management already covers the general idempotency principle and its state: present mechanics; this section is about the specific tools Ansible gives you to keep that promise honest task by task. Purpose-built modules — package, template, service, user, lineinfile, copy — each implement their own "check current state, act only on the gap" logic internally and report back ok (nothing needed changing) or changed (it did something) accordingly. That per-task changed/ok signal is what drives handlers, and it's the thing worth reading in every playbook's output before trusting a run.
ansible-playbook --check is a dry run: Ansible reports what would change without changing anything. Add --diff and modules that support it — template, copy, lineinfile, blockinfile — print the exact content delta, the same way a code reviewer reads a pull request diff. Not every module can honor check mode meaningfully: command and shell run an arbitrary program, and Ansible has no way to know in advance what that program would do, so by design they either skip in check mode or require an explicit opt-in.
# A command task that isn't naturally idempotent, made safe two different ways
- name: Run the database migration exactly once
ansible.builtin.command: /opt/app/migrate.sh
args:
creates: /opt/app/.migrated # skip entirely if this file already exists
- name: Check disk usage (read-only, never actually "changes" anything)
ansible.builtin.command: df -h
register: disk_check
changed_when: false # override the default changed=true for command/shellchanged_when and failed_when override Ansible's default (and, for command/shell, fairly naive) notion of success and change, letting you tell it explicitly what "this task actually did something" means for a task that has no built-in state check. register captures a task's output into a variable so a later task can act on it conditionally, rather than re-running work that's already been confirmed done.
Handlers — the notify: pattern from the nginx example on configuration management — only fire if the task that notified them reported changed, and by default they run once, batched at the end of the play, even if three separate tasks notify the same handler. If a later task in that same play fails before the play ends, queued handlers are skipped entirely by default — pass --force-handlers to run them anyway, which matters whenever "did the reload actually happen" needs a reliable answer regardless of what failed afterward.
Day-to-day commands
☺ Like you're 10: A handful of commands cover almost everything: check who's reachable, run one command everywhere, run the whole playbook, preview it first, install shared content, keep secrets locked.
# ad-hoc: run one module against a pattern of hosts, no playbook needed $ ansible all -i inventory/hosts.yml -m ping $ ansible web -i inventory/hosts.yml -a "uptime" --become # the real workhorse — run a playbook $ ansible-playbook -i inventory/hosts.yml site.yml $ ansible-playbook -i inventory/hosts.yml site.yml --check --diff # dry run, show the delta $ ansible-playbook -i inventory/hosts.yml site.yml --limit web-01.internal --tags nginx $ ansible-playbook -i inventory/hosts.yml site.yml -e "app_version=2.4.0" # dynamic inventory — list and sanity-check what a plugin actually resolves $ ansible-inventory -i inventory/aws_ec2.yml --graph $ ansible-inventory -i inventory/aws_ec2.yml --host web-01.internal # Galaxy — install shared collections and roles, pinned by requirements.yml $ ansible-galaxy collection install amazon.aws $ ansible-galaxy role install geerlingguy.nginx $ ansible-galaxy install -r requirements.yml # secrets — never commit a plaintext password to a playbook or group_vars file $ ansible-vault create group_vars/prod/vault.yml $ ansible-vault edit group_vars/prod/vault.yml $ ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt # lint before you ship it $ ansible-lint site.yml $ ansible-doc -l | grep nginx # what modules exist, without leaving the terminal
Gotchas and failure modes
☺ Like you're 10: SSH doesn't scale the way a control loop does, Python isn't always where you expect it to be, and a raw shell command will happily lie to you about being idempotent.
Fanout is bounded by forks, not by your patience. Against a thousand hosts at the default forks = 5, Ansible works through them roughly two hundred batches at a time — correct, but slow enough that teams routinely raise forks into the dozens or low hundreds. Push past what the control node's CPU, memory, and open-file limits can sustain, or past what a shared bastion's SSH session limit allows, and the run starts failing in ways that look like flaky infrastructure rather than what it actually is: too much concurrency for the box doing the pushing.
Python interpreter discovery is a real trap on mixed fleets. A host running RHEL 8, one running Ubuntu 22.04, and one running a minimal container base image can each expose Python at a different path, or under a different major version, or not pre-installed at all. Auto-discovery gets it right most of the time and silently guesses wrong occasionally — pin ansible_python_interpreter explicitly in group_vars for any group where "it worked on my laptop's target but not that one host" has ever happened.
Fact gathering is a hidden tax on every play. Unless told otherwise, Ansible runs the setup module against every targeted host at the start of every play to collect facts (OS, network interfaces, memory, and dozens more), and on a large fleet that alone can take longer than the actual work. Set gather_facts: false on plays that don't need facts, or turn on fact caching (a jsonfile or redis backend in ansible.cfg) so facts are reused across runs within a TTL instead of re-collected from scratch every single time.
Vault key management is where secrets discipline actually lives or dies. ansible-vault encrypts a file at rest, but the encrypted blob is only as safe as the password or key protecting it — teams that never rotate a single shared vault password, or that commit a vault-encrypted file without documenting which --vault-id label decrypts it, end up with secrets nobody can rotate without a coordinated scramble. For centralized issuance and rotation instead of a static encrypted file, pair Ansible with a real secrets manager like HashiCorp Vault via the community.hashi_vault collection — see Secrets & Credential Management for the broader pattern this fits into.
Shell and command tasks are the most common false idempotency. A shell task that runs echo "export PATH=..." >> ~/.bashrc appends a fresh duplicate line on every single run, because shell and command are raw escape hatches with no built-in state check at all — configuration management covers this trap in depth; the fix is the same one shown above, a purpose-built module where one exists, or a creates:/removes: guard when it doesn't.
A play that runs perfectly by hand from your laptop can hang forever on a CI runner. If become: true needs a sudo password and the target's sudoers file doesn't grant the automation account passwordless NOPASSWD access, Ansible prints an interactive password prompt — on a runner with no terminal to answer it. There's no error and no default timeout, just a job sitting idle until CI's own timeout eventually kills it, usually with a confusing message that has nothing to do with sudo. Fix it at the source with a NOPASSWD sudoers entry for the automation user, or supply --ask-become-pass deliberately with a vaulted variable — never leave a pipeline silently waiting on a TTY that is never going to appear.
On a throwaway VM or a couple of local containers with SSH enabled: write the inventory/hosts.ini, the nginx role, and site.yml from this page, then run ansible-playbook -i inventory/hosts.ini site.yml --check --diff first and read exactly what it says it would do. Apply for real, then run the identical command again and confirm every task reports ok, not changed — that's the idempotency contract, proven rather than assumed. Now hand-edit /etc/nginx/nginx.conf directly on the host to simulate drift, and re-run the playbook: watch the template task report changed, the handler fire, and the file snap back to match the template.
Ansible vs. alternatives — and when to pair, not choose
☺ Like you're 10: Other tools reach the same "make it match" goal differently — an agent that never sleeps, a different push mechanism, or Ansible itself pointed at a cloud API instead of a server.
| Option | Model | Best when | Costs you |
|---|---|---|---|
| Ansible | Agentless, SSH-based push, YAML playbooks | Nothing should be pre-installed on managed hosts; ad-hoc and scheduled runs both matter; Python/SSH is already everywhere in the fleet | No continuous self-healing between runs; SSH fanout is the scaling bottleneck; command/shell tasks are easy to write non-idempotently by accident |
| Puppet | Agent-based pull, periodic reconciliation against a central server | Unauthorized drift is a bigger risk than deployment latency; very large, homogeneous fleets that benefit from each host reconciling itself | An agent and a Puppet Server to install, patch, and scale; certificate-based trust to manage |
| Chef | Agent-based pull, Ruby DSL "recipes" and "cookbooks" | Team already fluent in Ruby; complex conditional configuration logic benefits from a real language | Steeper learning curve than YAML; same agent/server operational overhead as Puppet |
| Terraform | Declarative provisioning with a tracked state file and a plan/diff step | The resource doesn't exist yet and needs to be created, resized, or destroyed | Different job entirely — configuring what's already running is not what it's built for |
| Ansible + Terraform together | Terraform provisions and tags; Ansible's dynamic inventory configures what Terraform just created | Almost every real cloud pipeline — this is the default pattern, not the exotic one | Two tools, two mental models, one pipeline to wire correctly between them |
The practical rule mirrors the one the DevOps toolchain page already draws: pick the tool for the job in front of you, not a single tool for every job. Reach for Ansible specifically when the change is "configure what's already there" and agentless simplicity matters more than continuous self-healing; reach for a pull-based agent when unauthorized drift between runs is the risk you most need covered; and expect Terraform to sit immediately upstream of Ansible in nearly every real pipeline, not in competition with it. Practice the full hand-off hands-on in Capstone Part 2 — Infrastructure as Code, or drill writing a reusable module in Drill — Write a Reusable IaC Module. If Ansible specifically is your focus, the Red Hat Certified Specialist in Ansible Automation page has exam logistics and prep guidance.
Recon the Robot: Terraform just finished creating three instances tagged env=prod. My dynamic inventory picked them up automatically — no static hosts file to hand-edit.
Foxy: So Ansible provisioned them?
Recon the Robot: No. Terraform provisioned them. I only configure what's already there — that boundary doesn't blur just because we run back to back in the same pipeline.
Benny the Beaver: I wired the pipeline that way on purpose: terraform apply, then ansible-playbook against the aws_ec2 plugin. Two tools, two jobs, one stage each.
Gizmo: Or skip all that — just bolt a local-exec provisioner onto the Terraform resource that shells out to ansible-playbook directly. One file, one apply. 🤑
Timmy the Turtle: Terraform's own docs call provisioners a last resort, Gizmo. Fail partway through that local-exec and Terraform still marks the resource applied — it has no idea the box never actually got configured.
Recon the Robot: And I ran the nginx role against web-02 six times today testing a template change. Every run: two ok, one changed exactly when the content changed, zero drift left behind. That's the whole idempotency contract, working as advertised.
1. In one sentence each, what does Terraform own and what does Ansible own in a pipeline running both — and why are the two usually paired rather than compared as competitors? 2. What exactly does a managed node need for Ansible to configure it, and what does it specifically not need? 3. Name the three things you write to describe a nontrivial Ansible setup: the file listing hosts, the file describing what should run, and the reusable directory structure for sharing tasks across playbooks. 4. Why does a raw shell: echo ... >> ~/.bashrc task fail the idempotency test, and name two ways to fix it. 5. What do --check and --diff do, and why does push-based Ansible need an explicit flag for this while a pull-based Puppet agent corrects drift automatically? 6. Name one specific operational gotcha that shows up once a fleet grows past a few dozen hosts, and its fix.
Check your answers
- Terraform owns provisioning — making a resource exist and tracking its identity in state. Ansible owns configuration — installing and maintaining software on a resource that already exists. They're paired rather than compared because they solve genuinely different, sequential problems: Terraform has no ongoing configuration-drift story, and Ansible has no state file or destroy semantics, so most real pipelines run both, one right after the other.
- An SSH server (or WinRM for Windows) and a Python interpreter — nothing else. It specifically does not need any Ansible-specific agent, daemon, or open management port installed ahead of time.
- An inventory (static INI/YAML or a dynamic plugin) listing the hosts and groups; a playbook (YAML) listing the plays and tasks to run against a host pattern; and a role — the standard
tasks/,handlers/,templates/,defaults/directory layout — as the reusable, shareable packaging unit for that content. - It fails idempotency because
shell/commandhave no built-in state check — they just run the command again, appending a fresh duplicate line every time. Fixes: use a purpose-built module instead where one exists (lineinfileorblockinfilefor this exact case), or guard the rawshell/commandtask with acreates:/removes:argument or an explicitchanged_when/conditional so re-running it is a safe no-op. --checkis a dry run that reports what would change without changing anything;--diffadds the exact content delta for modules that support it. Ansible needs the flag explicitly because it's push-based — nothing runs on a host between invocations, so detecting drift requires deliberately triggering a check. A pull-based Puppet agent doesn't need an equivalent flag because its normal periodic reconciliation run already compares and corrects state automatically, with no separate detection step.- Any one of: SSH fanout bottlenecked by the default
forks = 5(fix: raiseforksdeliberately, bounded by control-node capacity); ambiguous Python interpreter discovery across a mixed-OS fleet (fix: pinansible_python_interpreterper group); fact-gathering overhead on every play (fix:gather_facts: falsewhere facts aren't needed, or enable fact caching); orbecomehanging silently on a non-interactive CI runner withoutNOPASSWDsudoers (fix: grant passwordless sudo to the automation account, or supply--ask-become-passdeliberately).