Skip to main content
RunBook Academy

AnsibleLII · Anti-PatternsAnti-patterns of construction

Anti-pattern: shell everywhere

Intermediate⏱ ~26 minbash

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

Not yet marked complete on this device.

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

Service impact possiblethe anti-pattern - a real shape, not a caricature
- 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 nginx

Every 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.

Read-only / Safefind out how much of it you have
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 production

The 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

Read-only / Safea check run over shell tasks
$ ansible-playbook -i inventory/hosts.yml site.yml --check
TASK [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=0

ok=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

Read-only / Safethe same two tasks, run normally
$ ansible-playbook -i inventory/hosts.yml site.yml
TASK [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=0

command 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.

Service impact possiblethe report is now honest; the semantics are unchanged
- name: Add the config
ansible.builtin.shell: |
  echo "worker_processes 4;" >> /etc/nginx/nginx.conf
changed_when: false

changed_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

Configuration changethe same five operations, with modules
- 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: true

Four 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:

  1. No module exists, and you have looked - ansible-doc -l, then Galaxy, then Part LI lesson 1 alternatives.
  2. A vendor CLI is the only supported interface, and using anything else voids support.
  3. 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.
  4. 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

  1. Q1. A team runs their playbook with --check before every production change. The playbook is mostly shell tasks. What does the check run prove?

  2. Q2. Which capabilities are lost when a module is replaced by an equivalent shell task? Select all that apply.

  3. 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.

  4. Q4. A repository is 60% shell tasks. What is the highest-value first move?

Passing score: 75%. Answers are checked in this browser.