How to Use Ansible Playbooks to Replace Ad Hoc SSH Loops
Replace a for loop of ssh commands with an Ansible playbook: inventory, modules, idempotent tasks, check mode and the cases where a plain loop is still fine.

The first version of fleet automation is almost always a loop: for h in $(cat hosts); do ssh $h 'sudo apt-get update'; done. It works until one host is down, the command fails halfway or someone needs to know what changed. Ansible is the usual next step, and it runs over the same SSH you already use. It is the tool-based form of the principle in idempotent shell scripts. The examples assume ansible-core 2.17.
The loop and its problems
for h in web1 web2 web3; do
ssh "$h" 'sudo systemctl restart nginx'
doneIf web2 is unreachable the loop moves on and nothing records it. Output from three hosts interleaves, there is no parallelism, and rerunning does everything again whether it needed it or not. Each fix adds a line of shell until the loop is a bad configuration tool.
Inventory and an ad hoc command
[web]
web1.example.test
web2.example.test
web3.example.test
[web:vars]
ansible_user=deployansible web -i inventory.ini -m ping
ansible web -i inventory.ini -b -m service -a "name=nginx state=restarted"web1.example.test | SUCCESS => {"changed": false, "ping": "pong"}
web2.example.test | UNREACHABLE! => {"msg": "Failed to connect to the host via ssh"}
web3.example.test | SUCCESS => {"changed": false, "ping": "pong"}
The inventory holds hosts and variables once. An ad hoc command with -m runs a module in parallel, five hosts at a time by default, and an unreachable host is reported by name. -b escalates to root with sudo.
The same task as a playbook
---
- name: Configure web servers
hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Deploy the site config
ansible.builtin.copy:
src: files/site.conf
dest: /etc/nginx/conf.d/site.conf
mode: "0644"
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloadedEach task states a desired result, not a command. state: present installs nginx only if it is missing. The handler runs once, and only if the config file actually changed, which is hard to build with a plain loop. Run it with ansible-playbook -i inventory.ini site.yml.
Preview and read the result
ansible-playbook -i inventory.ini site.yml --check --diffPLAY RECAP *********************************************************
web1.example.test : ok=3 changed=1 unreachable=0 failed=0
web2.example.test : ok=3 changed=0 unreachable=0 failed=0
web3.example.test : ok=3 changed=1 unreachable=0 failed=0
Check mode predicts changes without making them, and --diff shows file differences, much like the dry run mode built by hand for shell scripts. The recap is a per-host audit trail. --limit web1 restricts a run to one host for a canary.
Why the loop breaks down as the fleet grows
A for host in $(cat hosts) loop with ssh inside is a fine tool for five servers and a bad one for fifty. It runs strictly one host at a time, so a single unreachable machine holds up everything behind it until the connection times out. It has no memory of what already succeeded, so a rerun after a partial failure repeats work or breaks on a step that is not safe to repeat. And it gives the reader no summary, only a long scroll of output in which the failures are hard to find.
Each of these has a direct answer in a configuration management tool. Ansible runs against many hosts in parallel with a configurable fork count, tracks the result per host, and prints a recap at the end that lists exactly which hosts changed, failed or could not be reached. The shift is less about new features than about replacing a procedure with a description of the desired state.
Idempotence is the main benefit
Most Ansible modules check the current state and act only when it differs. The package module installs a package if it is absent and reports "ok" if it is already there, while a shell loop that runs apt-get install prints noise on every run and gives no hint whether anything changed. Because a second run does nothing, a playbook can be rerun after a failure, scheduled, or applied to a freshly built machine with the same result.
The shell and command modules are the exception. They run whatever they are given and report "changed" every time, unless you add creates:, removes: or a changed_when: condition. When migrating a loop, translate each command to the module that owns the job, such as user, file, copy, template, service or lineinfile, and fall back to command with a guard only when no module fits. The result is shorter and states intent, which is easier to review than a pipeline of sed calls. The same principle applies to plain scripts, as in writing an idempotent shell script.
Inventories, variables and groups
The host list becomes an inventory, which can also carry structure. Group hosts by role, such as web and db, and by environment, and attach variables to a group so a setting is defined once. A playbook then targets a group, and the same file can configure staging and production with different values. Keep secrets out of plain inventory files; Ansible Vault encrypts a variable file with a password, and the playbook decrypts it at run time.
Connection settings live in the inventory or in ansible.cfg: the remote user, the key and the privilege escalation method. Using become: true runs a task through sudo, which replaces the habit of writing ssh host sudo ... and lets you require a password-less rule for only the commands the account needs.
Limits, rollouts and when to stop
Parallelism is a risk as well as a benefit. Restarting a service on every web server at once takes the site down. Set serial: 1 or a percentage on the play to roll through hosts in batches, and use max_fail_percentage to stop when too many fail. Combine that with a health check task between batches, so a bad change halts after the first server rather than the last.
Use --limit to run against one host first, and --check --diff to preview what would change. Not every module supports check mode, so treat the preview as a guide, not a guarantee. Keep the playbooks in version control, review changes like code, and run them from a central machine or CI, not from a laptop. If the job is a one-off command on a handful of hosts, the ad hoc form is enough, and a full playbook is more machinery than the job deserves.
Know when not to use it. A one-time change on three hosts does not need a repository of roles, and a quick ansible all -m command -a uptime is the right size. A fleet that changes constantly and needs drift detection may be better served by an agent-based system or by immutable images rebuilt from code. What the tool replaces is the unrecorded loop that only one person remembers how to run. Start with the smallest playbook that captures the task, commit it, and grow it only when the need appears.
Read the recap at the end of each run. A host listed as unreachable or failed means the change did not apply there, and a run that exits with success on a subset of the fleet is a partial rollout. Fix the cause, then rerun; idempotence makes that safe.
Where this does not apply
For a single read-only question on three hosts, such as disk usage, the loop or pdsh is faster to write than a playbook. Ansible needs Python on the managed hosts for most modules, and it adds a learning curve and a repository to maintain. For a handful of long-lived servers it may be more tooling than needed. When you need to create the servers themselves, look at Terraform. See the Ansible getting started guide and the builtin module index.


