AnsibleLII · Anti-PatternsAnti-patterns of construction
Anti-pattern: shell everywhere
What you'll learn
- Recognise the anti-pattern in a repository and estimate how widespread it is
- Name the four capabilities lost when a module is replaced by a shell task
- Explain why adding changed_when fixes the reporting without fixing the semantics
- Apply the corrected form and know the cases where a shell task is still right
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
This part catalogues the recurring failure shapes. Each lesson names an anti-pattern, states the incident it produces, and points at the part that teaches the corrected form. Disapproval on its own teaches nothing, so every lesson here is organised around a failure rather than a preference.
We start with the most common one by a wide margin.
What it looks like in a real repository
- name: Install nginx
ansible.builtin.shell: apt-get install -y nginx
- name: Add the config
ansible.builtin.shell: |
echo "worker_processes 4;" >> /etc/nginx/nginx.conf
- name: Create the app user
ansible.builtin.shell: useradd -m -s /bin/bash appuser
- name: Fix permissions
ansible.builtin.shell: chmod -R 755 /srv/app && chown -R appuser /srv/app
- name: Restart
ansible.builtin.shell: systemctl restart nginxEvery one of those has a module. Nobody wrote this file believing it was good; it accumulated, one task at a time, each written at a moment when looking up the module was slower than typing the command already in somebody’s shell history.
cd /srv/ansible
# Every command/shell task, with file and line.
grep -rnE '^\s*(ansible\.builtin\.)?(shell|command):' --include='*.yml' --include='*.yaml' .
# As a proportion of all tasks.
shell_tasks=$(grep -rcE '^\s*(ansible\.builtin\.)?(shell|command):' --include='*.yml' . | awk -F: '{s+=$2} END {print s}')
all_tasks=$(grep -rcE '^\s*- name:' --include='*.yml' . | awk -F: '{s+=$2} END {print s}')
echo "shell/command tasks: $shell_tasks of $all_tasks"
# ansible-lint finds the specific ones with known module replacements.
ansible-lint --profile productionThe failure, in four parts
1. No idempotency, so a rerun re-performs the action
echo "worker_processes 4;" >> /etc/nginx/nginx.conf appends. Run the
playbook three times and the file has three copies of the line.
This is not a cosmetic problem, because rerunning is your only recovery move. Part XLIV established the situation: a run stops halfway through a fleet, and you fix the cause and run again. With shell tasks that append, create or increment, the rerun is a second change on the 220 hosts that already succeeded.
2. No check mode, so --check proves nothing
$ ansible-playbook -i inventory/hosts.yml site.yml --checkTASK [Query the effective user] ************************************************
skipping: [localhost]
TASK [The same query with no changed_when] *************************************
skipping: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=0 changed=0 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0ok=0 changed=0 failed=0. A recap with no failures in it, from a run
that executed nothing and verified nothing.
3. No diff, so nobody can review what changes
--diff shows the before-and-after of a file a module manages. A shell
task that writes a file produces no diff, so the review question “what
will this actually change on the host” has no answer short of running it.
4. Reporting that does not match reality
$ ansible-playbook -i inventory/hosts.yml site.ymlTASK [Query the effective user] ************************************************
ok: [localhost]
TASK [The same query with no changed_when] *************************************
changed: [localhost]
PLAY RECAP *********************************************************************
localhost : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0command reports changed whenever the command exits zero, because
that is the only thing it can observe. On a converged fleet that means
changed=1 on every run, every handler subscribed to it firing, and the
drift signal from Part XXXVI permanently noisy.
“But I added changed_when”
This is the sophisticated version of the anti-pattern, and it is worth
separating carefully, because changed_when is a genuinely good tool
that Part XII teaches in earnest.
- name: Add the config
ansible.builtin.shell: |
echo "worker_processes 4;" >> /etc/nginx/nginx.conf
changed_when: falsechanged_when: false says “this task never changes anything”. The task
appends a line to a production config file on every run. The recap is
now clean and wrong, which is worse than noisy and right: the drift
report says nothing changed, so nobody looks.
Part LII lesson 5 covers blanket changed_when: false as its own
anti-pattern, because it appears independently of shell tasks.
The corrected form
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Set the worker process count
ansible.builtin.lineinfile:
path: /etc/nginx/nginx.conf
regexp: '^\s*worker_processes\s'
line: 'worker_processes 4;'
validate: 'nginx -t -c %s'
notify: Restart nginx
- name: Create the app user
ansible.builtin.user:
name: appuser
shell: /bin/bash
create_home: true
- name: Own the application directory
ansible.builtin.file:
path: /srv/app
state: directory
owner: appuser
mode: '0755'
recurse: trueFour differences that matter, beyond taste. The lineinfile task
replaces rather than appends, so a rerun converges. validate: refuses
to install a config that nginx -t rejects — the render-check-activate
discipline from Part XVII. The restart is a handler, so it fires only
on a genuine change rather than every run. And every one of these
supports --check and --diff, so the pre-flight run actually
pre-flies.
When a shell task is still right
Part IX covers this properly. The short list:
- No module exists, and you have looked - ansible-doc -l, then Galaxy, then Part LI lesson 1 alternatives.
- A vendor CLI is the only supported interface, and using anything else voids support.
- A genuinely one-shot operation during an incident, where the cost of writing it properly exceeds its remaining lifetime - and it does not get committed as a permanent task.
- You need shell features: a pipeline, a redirect, globbing, or an environment variable expanded by the shell. That is what shell is for, as opposed to command.
In every case the obligations come with it: an explicit changed_when
derived from evidence, a failed_when if the exit code is not a
reliable signal, creates: or removes: where they apply, and a
when: guard that reads current state first. That is the difference
between a shell task and this anti-pattern.
Knowledge check
Knowledge check · 4 questions
Q1. A team runs their playbook with --check before every production change. The playbook is mostly shell tasks. What does the check run prove?
Q2. Which capabilities are lost when a module is replaced by an equivalent shell task? Select all that apply.
Q3. Adding `changed_when: false` to a shell task that appends a line to a config file makes the recap clean but leaves the task appending on every run.
Q4. A repository is 60% shell tasks. What is the highest-value first move?
Passing score: 75%. Answers are checked in this browser.