Skip to main content
RunBook Academy

AnsibleIX · command, shell and rawcommand, shell and raw

command: no shell, and why that is the safe one

Intermediate⏱ ~16 minansible-playbookansible-doc

What you'll learn

  • Explain how ansible.builtin.command turns a free-form string into an argument vector
  • Predict which shell features do and do not work under command
  • Use argv, chdir, stdin and expand_argument_vars correctly
  • Describe exactly what command does under --check, and why
  • Explain why a command task reports changed on every run

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.command runs a program on the managed node. It does not run a shell. That single sentence is responsible for every surprising thing about the module, and for the reason this course prefers it over ansible.builtin.shell by default.

Most people meet command by writing a task that works, then writing a second task that mysteriously does not:

Configuration changetwo tasks, one works
- name: Reload the application config
ansible.builtin.command: /usr/sbin/appctl reload

- name: Archive yesterday's logs
ansible.builtin.command: cat /var/log/app/*.log > /var/log/app/archive.txt

The second task does not write an archive. It does not fail either — it hands cat five literal arguments, one of which is the string >, and cat reports that it cannot open a file called >. The playbook shows you a failed task with an error message about a filename you never typed.

What the module does with your string

The free-form string is not passed to a shell. It is split into an argument list by Python’s shlex, and that list is handed to the operating system’s exec family directly.

shlex understands quoting, and nothing else. It knows that echo "hello world" is two arguments. It does not know what *, >, |, ;, &, &&, backticks or $(...) mean, because those are shell syntax and there is no shell in this path.

Read-only / Safemetacharacters are literal
$ ansible-playbook -i localhost, expand.yml
TASK [command, glob and pipe are literal] ***************************************
ok: [localhost]

TASK [Results] *****************************************************************
ok: [localhost] => {
  "msg": [
      "metacharacters : * | >   cmd=['/bin/echo', '*', '|', '>']"
  ]
}

That is the whole security property. A string that reaches command cannot grow a second command, because there is nothing on the far end that knows how to parse one. The limitation and the safety are the same mechanism seen from two sides.

Four ways to write the same task

FormWhen to use it
Free form: ansible.builtin.command: /usr/bin/foo -xShort, unambiguous invocations.
cmd: as a mapping keySame as free form, but readable next to other options.
argv: as a listAny argument that contains a space, a quote, or a value from a variable.
Free form plus an args: blockLegacy style; cmd: reads better today.
Configuration changethe four forms
- name: Free form
ansible.builtin.command: /usr/sbin/appctl reload

- name: cmd as an option
ansible.builtin.command:
  cmd: /usr/sbin/appctl reload
  chdir: /opt/app

- name: argv, one element per argument
ansible.builtin.command:
  argv:
    - /usr/sbin/appctl
    - --config
    - /etc/app/main config.yml

- name: Free form with an args block
ansible.builtin.command: /usr/sbin/appctl reload
args:
  chdir: /opt/app

The third form is the one worth internalising. /etc/app/main config.yml contains a space; in the free-form string it would need quoting, and the moment that path comes from a variable the quoting becomes your problem in a way the next two lessons are entirely about. As a list element it is simply one argument.

The options that matter

Read-only / Saferead the contract
$ ansible-doc ansible.builtin.command
OPTIONS (red indicates it is required):

 argv    Passes the command as a list rather than a string.
 chdir   Change into this directory before running the command.
 cmd     The command to run.
 creates  A filename or (since 2.0) glob pattern. If a matching file
          already exists, this step will not be run.
 expand_argument_vars  Expands the arguments that are variables ...
 removes  A filename or (since 2.0) glob pattern. If a matching file
          exists, this step will be run.
 stdin   Set the stdin of the command directly to the specified value.
 stdin_add_newline  If set to true, append a newline to stdin data.
 strip_empty_ends  Strip empty lines from the end of stdout/stderr.

chdir is a real chdir(2) before exec, not a cd prefix on a shell line. stdin writes a string to the process’s standard input, which is how you feed a value to a program that insists on reading one without putting it on a command line where ps can see it.

expand_argument_vars

Since ansible-core 2.16 the module expands environment variables in arguments itself, in Python, before exec. This is the one place where something that looks like shell behaviour happens without a shell — and it behaves differently from a shell in a way worth knowing.

Read-only / SafePython expansion, not shell expansion
$ ansible-playbook -i localhost, expand.yml
ok: [localhost] => {
  "msg": [
      "expand default : /home/deploy $NOT_SET_ANYWHERE   cmd=['/bin/echo', '$HOME', '$NOT_SET_ANYWHERE']",
      "expand disabled: $HOME   cmd=['/bin/echo', '$HOME']"
  ]
}

Illustrative output

Two things in that output pay for themselves later. An unmatched variable is left as the literal text, where a shell would have substituted the empty string — so a typo in an environment variable name produces a nonsense argument rather than a silently missing one. And the cmd field in the result records the arguments before expansion, which is what you will be reading in a post-incident -vvv transcript.

Set expand_argument_vars: false when an argument legitimately contains a dollar sign — a crypt-format password hash, an AWK program, a regular expression with $ anchors.

command under --check

This is where most people are wrong about command, and it matters because check mode is the pre-flight tool the rest of this course leans on.

ansible-doc states the attribute plainly: check-mode support is partial, and the detail says the support consists entirely of creates and removes.

Read-only / Safewhat --check actually does
$ ansible-playbook -i localhost, --check checkmode.yml
TASK [command with no guard] ***************************************************
skipping: [localhost]

TASK [command guarded by creates] **********************************************
ok: [localhost]

TASK [shell with no guard] *****************************************************
skipping: [localhost]

TASK [raw] *********************************************************************
skipping: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=1    changed=0    unreachable=0    failed=0    skipped=3

An unguarded command task in check mode is skipped. It does not run, and it does not predict. That has a consequence people meet at the worst possible moment: a play whose real work happens in command tasks will pass --check cleanly while proving nothing at all, and every task that depends on a variable registered from one of those skipped tasks will then fail or behave differently.

Why every command task reports changed

A command task that ran shows changed. Always. A command task that did nothing but read a file still shows changed.

Read-only / Safea strictly read-only command, reported as a change
$ ansible-playbook -i localhost, changed.yml
TASK [Read the current user, no changed_when] **********************************
changed: [localhost]

TASK [Read the current user, changed_when derived from output] *****************
ok: [localhost]

TASK [Read the current user, guarded by creates] *******************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=1    unreachable=0    failed=0    skipped=0

The consequences are not cosmetic. A changed result fires handlers, so a read-only probe written as a command task will restart a service every single run. It also destroys the one number an operator reads first in a recap, and the one an auditor reads first in a log. Lesson 5 of this part covers the three correct fixes, in order of preference.

Things that are no longer true

Two pieces of command folklore are still widely repeated:

  • warn: does not exist. It was the parameter that suppressed the old “consider using the apt module” warning, and it was removed. Pass it today and the task fails on an unsupported parameter.
  • executable: is accepted but ignored, with a warning: “As of Ansible 2.4, the parameter ‘executable’ is no longer supported with the ‘command’ module.” If you need to choose an interpreter, you are asking for shell, script, or an explicit interpreter as argv[0].

Blast radius

command runs on every host the play targets, in parallel, up to forks. There is no dry run for it worth the name, and no rollback for whatever it did.

Before running a play whose work is in command tasks:

Read-only / Safepre-flight
# Which hosts will this actually touch?
ansible-playbook -i inventories/prod site.yml --limit webservers --list-hosts

# Which tasks, including those pulled in by roles and includes?
ansible-playbook -i inventories/prod site.yml --limit webservers --list-tasks

Both are read-only, both take a second, and both answer a question the run itself will not.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A task reads ansible.builtin.command: tar -czf /backup/app.tgz /srv/app/* — what reaches tar?

  2. Q2. Which of these are genuine reasons to prefer the argv list form over the free-form string? Select all that apply.

  3. Q3. Running ansible-playbook --check on a play whose work is done in command tasks validates that those tasks will succeed.

  4. Q4. Why does a command task that only reads a value still report changed?

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