Skip to main content
RunBook Academy

AnsibleIX · command, shell and rawcommand, shell and raw

shell: when you genuinely need a shell, and what you accept

Intermediate⏱ ~17 minansible-playbookansible-doc

What you'll learn

  • Identify the three legitimate reasons to choose shell over command
  • Explain which shell interprets the string, and why that varies by distribution
  • Use the executable option to pin an interpreter, and know when that is needed
  • Recognise pipeline exit-status traps that make a failing task report success
  • Apply the course rule for choosing between command and shell

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.

ansible.builtin.shell is ansible.builtin.command with one difference: the string is handed to a shell on the managed node instead of being split and executed directly.

That one difference buys you pipes, redirection, globbing, command substitution, shell builtins and &&. It also transfers the entire correctness burden of shell quoting from Ansible to you, on every distribution in your fleet, forever. This lesson is about deciding whether that trade is worth making for a given task, and doing it deliberately rather than by reflex.

The three cases that justify it

There are exactly three things shell does that command cannot, and if your task needs none of them, it does not need shell.

NeedExampleWhy command cannot
A pipelinejournalctl -u app | grep -c OOMThe pipe is shell syntax.
A redirectappctl dump > /var/lib/app/state.jsonSo is >.
A shell builtinulimit -n, type -P fooThe builtin is not a program on disk.

Globbing and command substitution belong to the same list, but both are usually a sign that the work belongs somewhere else — find, the ansible.builtin.find module, or a registered variable.

Which shell, exactly

The documentation says the command runs “through a shell (/bin/sh) on the remote node”. That is precise and it is not reassuring, because /bin/sh is a different program on different systems.

Family/bin/sh is usuallyConsequences
Debian, UbuntudashNo [[ ]], no arrays, no local -n, no pipefail in older versions, no source (use .).
RHEL, Rocky, Almabash in POSIX modeMost bashisms happen to work, which is how the bug gets shipped.
Alpinebusybox ashIts own subset again.

A play written and tested on Rocky and then run on Ubuntu is the classic way to discover this. The task does not fail with “bashism not supported”; it fails with a syntax error, or worse, it succeeds and produces the wrong answer.

Service impact possiblepinning the interpreter
- name: Count OOM kills since boot
ansible.builtin.shell:
  cmd: journalctl -k --since=boot | grep -c 'Out of memory' || true
  executable: /bin/bash
register: oom_count
changed_when: false

executable is not a magic portability fix — it is a declaration. It says “this string is bash, and the host must have bash at this path”. If a host in the group does not, the task fails loudly, which is a better outcome than silently running under dash and getting a different answer.

The exit status trap

This one produces a green playbook over a broken host, and it is the most expensive shell mistake in production because nothing reports it.

A pipeline’s exit status is the exit status of its last command. The shell does not care that something earlier in the chain failed.

Service impact possiblea task that cannot fail
# Wrong: the task reports ok even when appctl does not exist.
- name: Read the current queue depth
ansible.builtin.shell: appctl stats | awk '/queue/ {print $2}'
register: queue_depth
changed_when: false

register then hands you a queue_depth.stdout that is the empty string, and every when: and every template downstream quietly treats an outage as “queue depth is nothing”.

Two fixes, both explicit:

Service impact possiblemaking the pipeline honest
# Option 1: ask for the behaviour you assumed, and pin a shell that has it.
- name: Read the current queue depth
ansible.builtin.shell:
  cmd: set -o pipefail && appctl stats | awk '/queue/ {print $2}'
  executable: /bin/bash
register: queue_depth
changed_when: false

# Option 2: no pipeline, no shell, no trap.
- name: Read the raw stats
ansible.builtin.command: /usr/sbin/appctl stats
register: app_stats
changed_when: false

- name: Extract the queue depth on the controller
ansible.builtin.set_fact:
  queue_depth: "{{ app_stats.stdout | regex_search('queue\\s+(\\d+)', '\\1') | first }}"

Option 2 is the better one more often than people expect. The transformation runs on the controller, where you can debug it, where it is the same on every host in the fleet, and where a change to it does not require touching a managed node at all.

Quoting is now your job, per host

Under command, an argument containing a space is one argument because shlex was told so, or because you used argv. Under shell, an argument containing a space is however many words the target’s shell decides it is.

Destructivethe same value, two modules
# shell: /bin/sh sees  rm -rf /srv/app data/current
- name: Remove the old release
ansible.builtin.shell: rm -rf {{ app_dir }}/current

# command with argv: one argument, whatever it contains
- name: Remove the old release
ansible.builtin.command:
  argv:
    - /bin/rm
    - -rf
    - "{{ app_dir }}/current"

The next lesson is entirely about this, because the space is the benign version of the problem and the semicolon is the other one.

Blast radius

shell runs on every targeted host in parallel and can do anything the remote user can do. Two habits keep that bounded:

Read-only / Safebefore you run it
# 1. Exactly which hosts?
ansible-playbook -i inventories/prod deploy.yml --limit 'webservers:&europe' --list-hosts

# 2. What does the string become for one of them?
ansible-playbook -i inventories/prod deploy.yml --limit web01.example.com --tags render-only

The second habit — a debug task that prints the assembled command string before any task runs it — costs one task and has caught more production mistakes than any linter. Lesson 3 shows how to build it.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task runs ansible.builtin.shell: appctl stats | awk "/queue/ {print $2}" and appctl is not installed on one host. What does the playbook report for that host?

  2. Q2. Which of these tasks genuinely require ansible.builtin.shell rather than ansible.builtin.command? Select all that apply.

  3. Q3. On a Debian or Ubuntu managed node, a shell task with no executable option is interpreted by dash rather than bash.

  4. Q4. What does check mode do to an unguarded ansible.builtin.shell task?

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