Use the command module for simple, idempotent commands that don't need shell features like pipes or redirects; it's safer and faster. Use shell only when you absolutely need shell processing (e.g., pipelines, $HOME expansion), but be aware it's not idempotent by default and introduces security risks like shell injection. Always prefer command over shell unless you have a specific reason not to.
✦ Definition~90s read
What is Ansible Command and Shell Modules?
The command module is the simplest way to execute a command on a remote host. It runs the command directly, without a shell, so it doesn't interpret shell operators like |, >, $, or *. This makes it safer for simple tasks like running a binary or a script.
★
Imagine you're a chef in a busy kitchen.
The shell module, by contrast, executes commands through /bin/sh (or the configured shell), so it supports pipes, redirects, environment variable expansion, and globbing. However, this flexibility comes with risks: shell injection if user input is involved, and non-idempotent behavior if not carefully managed.
The raw module is the lowest-level module — it sends the command over SSH and executes it directly, without requiring Python on the target host. This is essential for bootstrapping hosts that don't have Python installed (e.g., a minimal CentOS image).
The script module copies a local script to the remote host and executes it. It's perfect for complex logic that would be messy as an inline command, and it doesn't require Python on the target (it uses SCP and SSH).
All four modules are part of Ansible's "action" plugins. They are not idempotent by default — every execution runs the command, even if nothing has changed. To make them idempotent, you must use creates (skip if a file/directory exists) or removes (skip if a file/directory does not exist) flags.
For example, command: /usr/bin/mkdir -p /data creates=/data ensures the mkdir only runs if /data doesn't exist.
Plain-English First
Imagine you're a chef in a busy kitchen. The command module is like giving a direct order to a line cook: "Chop these onions." The cook uses a knife (the executable) and does exactly what you say — no shortcuts, no interpretation. The shell module is like telling a sous chef: "Prepare the mise en place for the onion soup." The sous chef decides to use a food processor, a mandoline, or even delegate to a junior cook (piping, redirection, variable expansion). That flexibility is powerful but dangerous — if you say "use the sharpest knife" and the sous chef interprets it as "use the cleaver," you might get chunks instead of dice. The raw module is like shouting an order through a kitchen window when the line cook doesn't speak your language — it bypasses all normal communication channels (no Python on the host). And the script module is like handing the cook a printed recipe card from your own notebook — you wrote it locally, but they execute it remotely. The key lesson: use the simplest tool that gets the job done, and always add a "check if the onions are already chopped" (creates/removes flags) to avoid chopping them twice.
I still remember the 2 AM call. Our deployment pipeline had been running smoothly for weeks, but that night, a new Ansible playbook — written by a well-meaning junior engineer — reformatted all our application servers. The culprit? A shell task that ran mkfs.ext4 /dev/xvdf without checking if the filesystem already existed. The creates flag was missing. The result: 12 production servers wiped clean. That incident taught me the hard way that Ansible's command and shell modules are powerful but unforgiving. They're the sledgehammers of automation: effective but easy to cause collateral damage.
This article is about understanding the subtle but critical differences between Ansible's command, shell, raw, and script modules. These modules are often the first tools beginners reach for, but they're also the most misused. I'll show you exactly when to use each, how to make them idempotent with creates and removes flags, and — most importantly — when to avoid them entirely and use purpose-built modules like yum, copy, or service.
We'll cover real production incidents, debugging guides, and a quick-reference cheat sheet you can print and tape to your monitor. By the end, you'll never accidentally wipe a filesystem again — and you'll know exactly why command doesn't expand $HOME and why that matters.
Command vs Shell: The Fundamental Difference
The command module runs a command directly, without a shell. This means no shell expansion — no $HOME, no ~, no wildcards, no pipes. It's the safest option because it avoids shell injection vulnerabilities and is more predictable. The shell module, on the other hand, runs the command through /bin/sh (or the configured shell via executable parameter), so it supports all shell features.
When to use command: - Running a binary with arguments: command: /usr/bin/curl -s http://example.com - Creating a directory: command: mkdir -p /data - Checking a file: command: test -f /etc/passwd
When to use shell: - Piping output: shell: cat /var/log/messages | grep error - Using environment variables: shell: echo $HOME - Redirection: shell: echo hello > /tmp/hello.txt
Production gotcha: Never use shell with user input. If you must, sanitize with | quote filter. Example: shell: grep {{ pattern | quote }} /var/log/syslog.
If you use command: echo $HOME, it will literally print $HOME because there's no shell to expand it. Use shell or pass the environment variable explicitly via environment parameter.
📊 Production Insight
In one deployment, a junior engineer used command: cp /tmp/{{ item }} /opt/ inside a loop. The files were not being copied because * wasn't expanded. We switched to shell with cmd: cp /tmp/{{ item }} /opt/ and it worked. But then we had injection risks. The proper fix was to use copy module.
🎯 Key Takeaway
Use command for simple executables; use shell only when you need shell features, and never with untrusted input.
thecodeforge.io
Ansible Modules Command Shell
The Raw Module: Bootstrapping Python-Less Hosts
The raw module is the escape hatch. It sends the command over SSH and executes it directly, without requiring Python on the target. This is critical for initial provisioning of minimal systems (e.g., Docker containers, embedded devices, or freshly installed OS images that lack Python).
When to use raw: - Installing Python on a host that has no Python: raw: yum install -y python3 - Running a one-off command on a constrained system: raw: uptime - Debugging when Python modules fail: raw: ls -la /tmp
Limitations: - No idempotency by default (use creates/removes if possible) - No fact gathering (you can't use setup without Python) - No error handling beyond return codes - Not idempotent for most operations
Production pattern: ```yaml - name: Bootstrap Python on minimal CentOS ansible.builtin.raw: cmd: yum install -y python3 become: yes changed_when: false # raw doesn't track changes
name: Now gather facts
ansible.builtin.setup:
# This will work because Python is now installed
```
Gotcha:raw does not use the Ansible module system, so it doesn't support args like creates. You have to implement idempotency manually with when conditions based on stat results (but stat requires Python). For bootstrapping, this is acceptable because it runs only once.
raw_bootstrap.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
---
- name: BootstrapPython on minimal host
hosts: baremetal
gather_facts: no
tasks:
- name: Checkif python is available
ansible.builtin.raw: which python3
register: python_check
ignore_errors: yes
- name: Install python3 via raw module
ansible.builtin.raw: apt-get update && apt-get install -y python3
when: python_check is failed
- name: Now gather facts with python
ansible.builtin.setup:
Output
PLAY [Bootstrap Python on minimal host] ************************************
TASK [Check if python is available] ******************************************
changed: [baremetal1]
TASK [Install python3 via raw module] ****************************************
changed: [baremetal1]
TASK [Now gather facts with python] ******************************************
ok: [baremetal1]
PLAY RECAP *********************************************************************
The raw module is the only way to execute commands on a host without Python. However, it also means no Ansible modules are available. Use it sparingly and only for bootstrapping.
📊 Production Insight
We had a fleet of Raspberry Pi devices running a minimal Linux image without Python. The initial playbook failed with 'python not found'. We added a raw task to install Python, then ran the rest of the playbook. The raw task was the only way to get Ansible working on those devices.
🎯 Key Takeaway
Use raw only to bootstrap Python on hosts that lack it; then switch to normal modules.
The Script Module: Running Local Scripts Remotely
The script module copies a local script (from the control node) to the remote host and executes it. It's ideal for complex logic that would be messy as an inline command, or when you need to reuse a script across multiple tasks.
When to use script: - Running a multi-line bash script that's too long for shell - Executing a Python script that needs to run on the remote host - When you want to version-control the script alongside your playbook
How it works: 1. Ansible SCPs the script to a temporary directory on the remote host. 2. The script is executed via the shell (default /bin/sh). 3. The temporary files are cleaned up after execution.
Idempotency with creates: ``yaml - name: Run setup script only if flag file missing ansible.builtin.script: cmd: /opt/ansible/scripts/setup.sh creates: /opt/app/.setup_done ``
Passing arguments: ``yaml - name: Run script with arguments ansible.builtin.script: cmd: /opt/ansible/scripts/deploy.sh --env production --version 1.2.3 ``
Production gotcha: The script must exist on the control node. If you use a relative path, it's relative to the playbook directory. Also, the script is executed with the remote user's shell, so ensure it has proper shebang (#!/bin/bash).
script_module.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
---
- name: Deploy and run a local script remotely
hosts: webservers
tasks:
- name: Run deployment script
ansible.builtin.script: /local/deploy.sh --env prod
args:
executable: /bin/bash
register: script_result
- name: Show script output
ansible.builtin.debug:
var: script_result.stdout_lines
Output
PLAY [Deploy and run a local script remotely] *******************************
If your shell command is longer than 10 lines, consider moving it to a script and using the script module. It's easier to test, maintain, and version control.
📊 Production Insight
We had a complex data migration that involved multiple steps, error handling, and logging. Inline shell was unreadable. We moved it to a Python script and used script module. The playbook became clean, and we could test the script independently.
🎯 Key Takeaway
Use script to run local scripts remotely — it's cleaner than inline shell and supports idempotency via creates.
thecodeforge.io
Ansible Modules Command Shell
Idempotency with Creates and Removes Flags
The command, shell, and script modules are not idempotent by default — they run every time the playbook executes. To make them idempotent, use the creates and removes flags. These flags tell Ansible to skip the task if a specified file or directory exists (or does not exist).
creases: Skip if the file/directory exists. ``yaml - name: Create data directory only if it doesn't exist ansible.builtin.command: cmd: mkdir -p /data creates: /data ``
removes: Skip if the file/directory does NOT exist. ``yaml - name: Remove temp file only if it exists ansible.builtin.command: cmd: rm /tmp/tempfile removes: /tmp/tempfile ``
Combined with changed_when: Sometimes the command itself has a different success indicator. For example, grep returns 0 if found, 1 if not found. You can combine creates with failed_when or changed_when.
Production pattern: ``yaml - name: Download archive if not already present ansible.builtin.shell: cmd: curl -o /tmp/package.tar.gz https://example.com/package.tar.gz creates: /tmp/package.tar.gz args: warn: false # Suppress curl warning ``
Gotcha:creates and removes only work with files/directories, not with command output. If you need to check command output, use register and when.
Why this matters: Without these flags, every task reports 'changed' on every run, making it impossible to know what actually changed. Worse, destructive commands (like rm -rf) run every time, potentially causing data loss.
creates_removes.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
---
- name: Idempotent file extraction
hosts: all
tasks:
- name: Extract archive only if target doesn't exist
ansible.builtin.command: tar xzf /tmp/app.tar.gz -C /opt/app
args:
creates: /opt/app/bin/start.sh
- name: Remove temp file only if it exists
ansible.builtin.command: rm /tmp/app.tar.gz
args:
removes: /tmp/app.tar.gz
Output
PLAY [Idempotent file extraction] *******************************************
TASK [Extract archive only if target doesn't exist] **************************
ok: [host1]
TASK [Remove temp file only if it exists] ************************************
changed: [host1]
PLAY RECAP *********************************************************************
⚠ Always Use Creates or Removes for Destructive Commands
If your command modifies the system (e.g., creates a user, formats a disk, deletes files), you must use creates or removes to prevent accidental re-execution. Otherwise, your playbook is not idempotent.
📊 Production Insight
I once saw a playbook that ran useradd without creates. Every run attempted to create the user, and Ansible reported 'changed' each time. Worse, if the user already existed, useradd would return non-zero and fail the playbook. The fix was to use the user module instead, which is idempotent by default.
🎯 Key Takeaway
Always add creates or removes to command/shell/script tasks to make them idempotent and safe for repeated runs.
When to Avoid Shell and Use Purpose-Built Modules
One of the most common mistakes beginners make is using shell for everything. But Ansible has a rich set of modules that are idempotent, handle edge cases, and provide clear error messages. Using shell for tasks that have a dedicated module is like using a sledgehammer to hang a picture — it works, but it's risky and ugly.
Examples of purpose-built modules to use instead of shell: - Package management:yum, apt, dnf, pip — instead of shell: yum install -y nginx - File operations:copy, template, file, lineinfile — instead of shell: cp /src /dst or shell: echo 'line' >> /etc/config - Service management:service, systemd — instead of shell: systemctl restart nginx - User management:user, group — instead of shell: useradd bob - Command execution with output:command with register — instead of shell: echo $(command)
Why purpose-built modules are better: - Idempotent by design (e.g., yum only installs if not already installed) - Better error handling (e.g., copy checks checksums) - Idempotency reporting (e.g., changed vs ok) - No shell injection risk - Easier to read and maintain
Production example: ```yaml # Bad: shell for package install - name: Install nginx ansible.builtin.shell: cmd: yum install -y nginx # Not idempotent, no error if package not found
When is shell acceptable? - When no dedicated module exists (e.g., running a custom tool) - When you need pipes, redirects, or complex shell features - When you're prototyping and plan to refactor later
Rule of thumb: If you can find an Ansible module for the task, use it. If not, consider writing a custom module or using command with creates.
avoid_shell.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
---
- name: Use purpose-built modules instead of shell
hosts: all
tasks:
- name: Copy file with permissions (avoid shell cp)
ansible.builtin.copy:
src: /local/config.conf
dest: /etc/myapp/config.conf
owner: root
group: root
mode: '0644'
- name: Ensure service is running (avoid shell systemctl)
ansible.builtin.systemd:
name: myapp
state: started
enabled: yes
- name: Createuser (avoid shell useradd)
ansible.builtin.user:
name: appuser
state: present
groups: www-data
shell: /bin/bash
Output
PLAY [Use purpose-built modules instead of shell] ***************************
TASK [Copy file with permissions (avoid shell cp)] ***************************
changed: [host1]
TASK [Ensure service is running (avoid shell systemctl)] *********************
changed: [host1]
TASK [Create user (avoid shell useradd)] *************************************
changed: [host1]
PLAY RECAP *********************************************************************
Run ansible-doc -l | grep <topic> to find if a module exists for your task. For example, ansible-doc -l | grep user shows user module.
📊 Production Insight
I once inherited a playbook with 50+ shell tasks, many of which were doing simple file operations. After refactoring to use copy, template, and lineinfile, the playbook became idempotent, faster, and much easier to debug. The number of 'changed' tasks dropped from 50 to 5 on subsequent runs.
🎯 Key Takeaway
Use purpose-built modules instead of shell whenever possible — they are idempotent, safer, and easier to maintain.
Environment Variables and Working Directory
Both command and shell modules support environment and chdir parameters. environment sets environment variables for the command, and chdir changes the working directory before execution.
Changing working directory: ``yaml - name: Run script from specific directory ansible.builtin.shell: cmd: ./deploy.sh chdir: /opt/myapp ``
Production gotcha:chdir only affects the command itself, not the entire task. If you need to run multiple commands in the same directory, consider using shell with cd /path && command1 && command2 or use script module with a script that changes directory.
Using environment with creates: ``yaml - name: Download file with env var ansible.builtin.command: cmd: curl -O https://example.com/file creates: /tmp/file environment: http_proxy: http://proxy:8080 ``
Note on shell and environment: When using shell, environment variables set via environment are available to the shell. However, the shell may also inherit environment variables from the remote user's profile. Use environment to override or add variables.
env_workdir.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
- name: Set environment and working directory
hosts: all
tasks:
- name: Run script with custom env and cwd
ansible.builtin.shell: ./deploy.sh
args:
chdir: /opt/myapp
executable: /bin/bash
environment:
APP_ENV: production
DB_HOST: db.internal
LOG_LEVEL: info
register: deploy_result
- name: Show environment variable used
ansible.builtin.debug:
msg: "Deploy ran in {{ deploy_result.chdir }} with APP_ENV={{ deploy_result.environment.APP_ENV }}"
Output
PLAY [Set environment and working directory] ********************************
TASK [Run script with custom env and cwd] ************************************
In command, environment variables are passed directly to the executable. In shell, they are available to the shell and can be used in the command string (e.g., echo $MY_VAR).
📊 Production Insight
We had a task that needed to run a Java application with a specific JAVA_HOME. Using environment with command worked perfectly. But when we tried to use shell with echo $JAVA_HOME, it printed the remote user's default Java home because the shell expanded it before our env var took effect. We had to set JAVA_HOME in the shell command itself: shell: JAVA_HOME=/usr/lib/jvm/java-11; echo $JAVA_HOME.
🎯 Key Takeaway
Use chdir and environment to control the execution context of command and shell tasks.
Error Handling and Return Codes
By default, Ansible considers a command successful if it returns exit code 0. Any non-zero exit code causes the task to fail. You can override this with failed_when and changed_when.
Ignoring errors: ``yaml - name: Try to stop service (might not be running) ansible.builtin.command: cmd: systemctl stop myapp ignore_errors: yes register: result ``
Custom failure condition: ``yaml - name: Check if file contains pattern ansible.builtin.shell: cmd: grep 'pattern' /var/log/app.log register: grep_result failed_when: grep_result.rc not in [0, 1] changed_when: false ``
Using stdout and stderr: ``yaml - name: Run script and capture output ansible.builtin.shell: cmd: /opt/scripts/deploy.sh register: deploy_result failed_when: deploy_result.rc != 0 or 'ERROR' in deploy_result.stderr ``
Production pattern for idempotent command with custom check: ``yaml - name: Initialize database if not already initialized ansible.builtin.shell: cmd: /opt/app/bin/init_db.sh register: init_result changed_when: '"already initialized" not in init_result.stdout' failed_when: init_result.rc != 0 ``
Gotcha:ignore_errors still marks the task as 'failed' in the output but continues the playbook. Use failed_when for more control.
error_handling.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
- name: Handle errors and return codes
hosts: all
tasks:
- name: Run command that may fail
ansible.builtin.command: /usr/bin/false
register: cmd_result
failed_when:
- cmd_result.rc != 0
- cmd_result.rc != 2 # allow rc=2 as acceptable
- name: Handle specific error
ansible.builtin.shell: grep "pattern" /var/log/syslog || true
register: grep_result
changed_when: false
- name: Fail with custom message
ansible.builtin.fail:
msg: "Deployment failed with rc={{ cmd_result.rc }}"
when: cmd_result.rc not in [0, 2]
Output
PLAY [Handle errors and return codes] ***************************************
TASK [Run command that may fail] *********************************************
Always register the result of command/shell tasks, especially in production. Use debug: var=result.stdout_lines to see output. This is invaluable for troubleshooting.
📊 Production Insight
We had a script that returned exit code 1 when a condition was met (e.g., database already migrated). The playbook kept failing. We added failed_when: result.rc not in [0, 1] to allow exit code 1 as a valid success indicator.
🎯 Key Takeaway
Use failed_when and changed_when to handle non-standard exit codes and output-based success conditions.
Security: Shell Injection and Avoiding Unsafe Practices
Using shell with user input is a recipe for shell injection. If you must use shell with dynamic values, sanitize them with the quote filter.
Better: use command with arguments: ``yaml - name: Safe grep with command ansible.builtin.command: cmd: grep args: argv: - grep - "{{ user_input }}" - /var/log/syslog ``
Using cmd parameter with list (avoids shell entirely): ``yaml - name: Execute with no shell ansible.builtin.command: cmd: /usr/bin/curl args: argv: - curl - -s - http://example.com/{{ endpoint | urlencode }} ``
Production rule: Never use shell with variables that come from user input, facts, or external sources. Use command with argv or the quote filter.
Additional security: - Use become only when necessary - Avoid using shell to run commands that change system state - Use no_log: true if the command contains sensitive data (e.g., passwords) - Set warn: false only if you understand the risk
If your playbook uses shell with {{ user_input }} and that input comes from a web form or API, an attacker could inject arbitrary commands. Always sanitize with | quote or use command with argv.
📊 Production Insight
We had a playbook that accepted a hostname from a CI variable and used it in a shell task to ping the host. One day, the variable was set to ; rm -rf /. Luckily, the playbook ran on a non-production host, but it was a wake-up call. We switched to command with argv and never looked back.
🎯 Key Takeaway
Avoid shell injection by using command with argv or sanitizing with quote filter when you must use shell.
Performance and Best Practices for Large-Scale Deployments
In large environments with hundreds of hosts, the overhead of running shell commands can add up. Here are some performance tips:
1. Use pipelining to reduce SSH connections. Set pipelining: True in ansible.cfg to combine multiple module operations into fewer SSH connections. This speeds up command execution significantly.
2. Avoid shell for simple commands.command is faster because it doesn't invoke a shell. For example, command: /usr/bin/mkdir -p /tmp is faster than shell: mkdir -p /tmp.
3. Use async for long-running commands. ``yaml - name: Long running script ansible.builtin.shell: cmd: /opt/long_script.sh async: 3600 poll: 30 register: long_result ``
4. Limit changed_when to reduce logging. If a command is not idempotent but you only care about failures, set changed_when: false to avoid cluttering the output.
5. Use delegate_to to run commands on a single host instead of all. ``yaml - name: Run once on first host ansible.builtin.command: cmd: /usr/bin/update_global_config run_once: true ``
6. Combine with serial for rolling updates. ``yaml - name: Rolling restart hosts: webservers serial: 5 tasks: - name: Restart nginx ansible.builtin.shell: cmd: systemctl restart nginx ``
Production insight: In a 500-host environment, switching from shell to command for simple tasks reduced playbook runtime by 15% because we avoided shell overhead. Combined with pipelining, we saw a 30% improvement.
performance_best_practices.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
---
- name: Optimize large-scale deployments
hosts: all
strategy: free
serial: 10
tasks:
- name: Batch restart services with throttle
ansible.builtin.shell: systemctl restart myapp
throttle: 5
register: restart_result
- name: Use async forlong-running tasks
ansible.builtin.shell: /opt/long_task.sh
async: 300
poll: 10
- name: Gather facts only once
ansible.builtin.setup:
gather_subset: min
when: ansible_facts is not defined
Output
PLAY [Optimize large-scale deployments] *************************************
TASK [Batch restart services with throttle] **********************************
changed: [host1]
changed: [host2]
...
TASK [Use async for long-running tasks] **************************************
changed: [host1]
TASK [Gather facts only once] ************************************************
ok: [host1]
PLAY RECAP *********************************************************************
Pipelining works only if requiretty is disabled for the remote user in /etc/sudoers. Otherwise, you'll get 'sudo: sorry, you must have a tty to run sudo' errors.
📊 Production Insight
We once had a playbook that took 2 hours to run across 1000 servers. By enabling pipelining, using command instead of shell where possible, and adding async for long tasks, we reduced runtime to 45 minutes.
🎯 Key Takeaway
Optimize performance by using command over shell, enabling pipelining, and using async for long-running tasks.
Debugging Common Errors with Command and Shell
Here are the most common errors and how to fix them:
Error: 'MODULE FAILURE' or 'Timeout (12s)' - Root cause: Command took too long. Increase timeout or use async. - Fix: command: /usr/bin/long_running timeout: 300
Error: 'rc: 127' command not found - Root cause: The executable is not in the PATH or not installed. - Fix: Use full path or install the package first.
Error: 'Syntax error: Unterminated quoted string' - Root cause: Mismatched quotes in shell command. - Fix: Use YAML multiline syntax or escape quotes properly.
Error: 'The module command is not available' - Root cause: Using shell module on a host without Python (should use raw). - Fix: Use raw module for bootstrap.
Error: 'changed: [host] => {'changed': True}' when nothing changed - Root cause: No creates/removes flag. - Fix: Add creates or removes.
Debugging steps: 1. Add -vvv to ansible-playbook command to see SSH output. 2. Use register and debug to see stdout/stderr. 3. Run the command manually on the target host to verify. 4. Check if the command requires a shell feature (pipes, etc.) and switch to shell if needed.
Example debugging playbook: ```yaml - name: Debug command hosts: all tasks: - name: Run command ansible.builtin.command: cmd: /usr/bin/curl -I http://localhost register: result ignore_errors: yes
name: Print stdout
ansible.builtin.debug:
var: result.stdout_lines
name: Print stderr
ansible.builtin.debug:
var: result.stderr_lines
name: Print rc
ansible.builtin.debug:
var: result.rc
```
debugging_errors.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---
- name: Debug common command/shell errors
hosts: all
tasks:
- name: Command not found - use full path
ansible.builtin.command: /usr/bin/myapp --version
register: cmd_result
ignore_errors: yes
- name: Debug stderr and rc
ansible.builtin.debug:
msg: "RC={{ cmd_result.rc }}, stderr={{ cmd_result.stderr }}"
when: cmd_result is failed
- name: Checkif command exists
ansible.builtin.stat:
path: /usr/bin/myapp
register: binary_stat
- name: Failif missing
ansible.builtin.fail:
msg: "Binary not found"
when: not binary_stat.stat.exists
Output
PLAY [Debug common command/shell errors] ************************************
TASK [Command not found - use full path] *************************************
Running ansible-playbook -vvv playbook.yml shows the exact SSH commands being executed, including the command string. This is invaluable for diagnosing syntax errors or path issues.
📊 Production Insight
A common issue we see is 'rc: 1' from a command that works manually. Often it's because the remote user's PATH is different (e.g., non-interactive shell). Using full paths in commands resolves this.
🎯 Key Takeaway
Debug by registering output, using -vvv, and testing commands manually on the target host with the same user.
Advanced Patterns: Combining Modules for Complex Workflows
In real production, you often need to combine command/shell with other modules. Here are some advanced patterns:
1. Conditional execution based on previous command output: ```yaml - name: Check if service is running ansible.builtin.shell: cmd: systemctl is-active myapp register: service_status changed_when: false failed_when: service_status.rc not in [0, 3] # 0=active, 3=inactive
3. Combining script with creates and register: ``yaml - name: Run migration script ansible.builtin.script: cmd: /opt/ansible/scripts/migrate.sh --version {{ version }} creates: /opt/app/.migrated-{{ version }} register: migrate_result failed_when: migrate_result.rc != 0 or 'ERROR' in migrate_result.stderr ``
4. Using environment with command for proxy settings: ``yaml - name: Download package behind proxy ansible.builtin.command: cmd: curl -O http://example.com/package.rpm creates: /tmp/package.rpm environment: http_proxy: http://proxy:8080 https_proxy: http://proxy:8080 ``
5. Idempotent database migration using shell with conditional: ``yaml - name: Run database migration if not already applied ansible.builtin.shell: cmd: /opt/app/bin/migrate.sh register: migration changed_when: '"Migration applied" in migration.stdout' failed_when: migration.rc != 0 ``
When looping with creates, each iteration checks for the file independently. This ensures idempotency per item.
📊 Production Insight
We used the pattern of checking service status with a shell command before restarting. This avoided unnecessary restarts and reduced downtime during rolling updates.
🎯 Key Takeaway
Combine command/shell with other modules and conditionals to build robust, idempotent workflows.
Migrating from Shell to Purpose-Built Modules: A Refactoring Guide
If you have a legacy playbook full of shell tasks, here's a systematic approach to refactor:
Step 1: Identify the task's goal. - Installing a package? → yum/apt/pip - Copying a file? → copy/template - Managing a service? → service/systemd - Creating a user? → user - Running a command that doesn't fit any module? → Keep as command or shell
Step 2: Replace with module. ```yaml # Before - name: Install nginx ansible.builtin.shell: cmd: yum install -y nginx
Production insight: We refactored a 2000-line playbook that was 80% shell tasks. After refactoring, the playbook was 1200 lines, ran 40% faster, and had zero 'changed' tasks on subsequent runs (down from 150).
Run ansible-doc -s yum to see a summary of the yum module, or ansible-doc yum for full documentation. This helps you find the right module for your task.
📊 Production Insight
One team had a shell task that did sed -i 's/old/new/g' /etc/config. We replaced it with the replace module: replace: path=/etc/config regexp='old' replace='new'. It was safer, idempotent, and easier to read.
🎯 Key Takeaway
Refactor shell tasks to purpose-built modules for idempotency, safety, and readability. It's one of the highest-ROI improvements you can make.
● Production incidentPOST-MORTEMseverity: high
The Great Filesystem Wipe of 2023
Symptom
All application servers became unreachable after a playbook run. Filesystem check showed empty ext4 partitions.
Assumption
The engineer assumed the mkfs command would fail if the filesystem already existed, because the disk was already formatted.
Root cause
The shell task ran mkfs.ext4 /dev/xvdf without creates=/dev/xvdf or creates=/mnt/data. mkfs does not check for existing filesystems by default — it overwrites them.
Fix
Added creates=/mnt/data to the task, and changed from shell to filesystem module: filesystem: fstype=ext4 dev=/dev/xvdf. Also added a pre-check using stat and register.
Key lesson
Never use shell or command for destructive operations without an idempotency guard.
Use purpose-built modules (like filesystem) that are idempotent by default.
Production debug guideSymptom → Root cause → Fix4 entries
Symptom · 01
Task always reports 'changed' even when nothing changed
→
Fix
Root cause: No creates/removes flag. Fix: Add creates=/path/to/file if the command creates a file, or removes=/path/to/file if it removes one. Example: - command: /usr/bin/touch /tmp/lock creates=/tmp/lock.
Symptom · 02
Command fails with 'No such file or directory' for a file that exists
→
Fix
Root cause: The command module does not expand ~ or $HOME. Fix: Use shell module or pass the full path. Example: - command: cat /home/user/file instead of cat ~/file.
Symptom · 03
Shell task fails with 'Syntax error: unexpected end of file'
→
Fix
Root cause: A pipe or redirect is being interpreted by the shell, but the command string is malformed. Fix: Use shell module and ensure the command is a single line or use | for multiline. Example: - shell: cat /var/log/messages | grep error.
Symptom · 04
Raw module fails with 'python: not found' on a minimal host
→
Fix
Root cause: The host has no Python installed; Ansible cannot gather facts. Fix: Use raw to install Python first. Example: - raw: yum install -y python3 then - setup: (gather facts).
★ Ansible Command and Shell Modules Quick Referenceprint this for your desk
Command always shows changed−
Immediate action
Check if task has `creates` or `removes`
Commands
ansible-doc -t module command | grep -A5 creates
grep -r 'creates:' site.yml
Fix now
Add creates=/path/to/output to the task
Shell task with pipes fails+
Immediate action
Verify you're using `shell` not `command`
Commands
ansible-doc -t module shell | grep -A5 'Pipes'
grep -rn 'shell:' playbooks/
Fix now
Change command to shell if pipes/redirects needed
Raw module fails on new host+
Immediate action
Check if Python is installed
Commands
ansible host -m raw -a 'which python3'
ansible host -m raw -a 'python3 --version'
Fix now
Run - raw: yum install -y python3 before any other tasks
Script module not found+
Immediate action
Check the script path is local, not remote
Commands
ls -la /path/to/script.sh
ansible host -m script -a '/path/to/script.sh'
Fix now
Ensure script exists on control node, not target
Shell injection warning+
Immediate action
Check if user input is used in the command
Commands
grep -rn 'shell:.*{{' playbooks/
ansible host -m command -a 'echo safe'
Fix now
Use command with args or quote filter, or use shell with cmd parameter and | quote
Ansible Command, Shell, Raw, and Script Module Comparison
Module
Shell Expansion
Idempotent by Default
Requires Python
Use Case
command
No
No (use creates/removes)
Yes
Simple executables, no shell features needed
shell
Yes
No (use creates/removes)
Yes
Pipes, redirects, env vars, complex commands
raw
No
No
No
Bootstrapping Python-less hosts, one-off commands
script
No (but runs via shell)
No (use creates/removes)
No
Running local scripts on remote hosts
purpose-built (e.g., yum, copy)
N/A
Yes
Yes
Package, file, service, user management
⚙ Quick Reference
12 commands from this guide
File
Command / Code
Purpose
command_vs_shell.yml
- name: Demonstrate command vs shell
Command vs Shell
raw_bootstrap.yml
- name: Bootstrap Python on minimal host
The Raw Module
script_module.yml
- name: Deploy and run a local script remotely
The Script Module
creates_removes.yml
- name: Idempotent file extraction
Idempotency with Creates and Removes Flags
avoid_shell.yml
- name: Use purpose-built modules instead of shell
When to Avoid Shell and Use Purpose-Built Modules
env_workdir.yml
- name: Set environment and working directory
Environment Variables and Working Directory
error_handling.yml
- name: Handle errors and return codes
Error Handling and Return Codes
security_shell_injection.yml
- name: Avoid shell injection
Security
performance_best_practices.yml
- name: Optimize large-scale deployments
Performance and Best Practices for Large-Scale Deployments
debugging_errors.yml
- name: Debug common command/shell errors
Debugging Common Errors with Command and Shell
advanced_patterns.yml
- name: Combine modules for complex workflow
Advanced Patterns
migrate_shell_to_module.yml
- name: Refactor shell commands to modules
Migrating from Shell to Purpose-Built Modules
Key takeaways
1
Use command for simple executables; use shell only when you need shell features like pipes, redirects, or environment variable expansion.
2
Always add creates or removes flags to command/shell/script tasks to make them idempotent and safe for repeated runs.
3
Use raw only to bootstrap Python on hosts that lack it; then switch to normal modules for idempotency and error handling.
4
Use script to run local scripts remotely
it's cleaner than inline shell and supports idempotency via creates.
5
Avoid shell for package installation, file operations, or service management
use purpose-built modules like yum, copy, or service instead.
6
Sanitize user input with | quote or use command with argv to prevent shell injection.
7
Use register and debug to capture and inspect command output for troubleshooting.
8
Enable pipelining in ansible.cfg and use command over shell for performance in large deployments.
INTERVIEW PREP · PRACTICE MODE
Interview Questions on This Topic
Q01JUNIOR
What is the difference between Ansible's `command` and `shell` modules?
Q02JUNIOR
How do you make a `command` or `shell` task idempotent?
Q03SENIOR
When would you use the `raw` module instead of `command` or `shell`?
Q04SENIOR
What is the `script` module and when is it useful?
Q05SENIOR
How can you prevent shell injection when using the `shell` module with u...
Q06JUNIOR
What is the purpose of the `creates` flag in Ansible?
Q07JUNIOR
How do you capture the output of a `command` or `shell` task?
Q08SENIOR
What are the performance implications of using `shell` vs `command`?
Q01 of 08JUNIOR
What is the difference between Ansible's `command` and `shell` modules?
ANSWER
The command module runs a command directly without a shell, so it does not support shell expansions like $HOME, pipes, or redirects. It's safer and faster. The shell module runs the command through /bin/sh, supporting all shell features. Use command for simple executables and shell only when you need shell features.
Q02 of 08JUNIOR
How do you make a `command` or `shell` task idempotent?
ANSWER
Use the creates or removes flags. creates tells Ansible to skip the task if a specified file or directory already exists. removes skips if the file does not exist. For example, command: /usr/bin/mkdir -p /data creates=/data ensures the directory is only created if it doesn't exist.
Q03 of 08SENIOR
When would you use the `raw` module instead of `command` or `shell`?
ANSWER
Use raw when the target host does not have Python installed, such as during initial bootstrapping of a minimal OS image. raw sends the command over SSH directly without requiring Python. After Python is installed via raw, you can switch to normal modules.
Q04 of 08SENIOR
What is the `script` module and when is it useful?
ANSWER
The script module copies a local script from the control node to the remote host and executes it. It's useful for complex logic that would be messy as an inline command, or when you want to version-control the script. It supports creates/removes for idempotency.
Q05 of 08SENIOR
How can you prevent shell injection when using the `shell` module with user input?
ANSWER
Use the quote filter to sanitize the input: shell: grep {{ user_input | quote }} /var/log/syslog. Better yet, use the command module with the argv parameter to pass arguments as a list, avoiding shell interpretation entirely: command: grep argv=['grep', '{{ user_input }}', '/var/log/syslog'].
Q06 of 08JUNIOR
What is the purpose of the `creates` flag in Ansible?
ANSWER
The creates flag makes a task idempotent by skipping execution if the specified file or directory already exists. It's used with command, shell, and script modules. For example, command: /usr/bin/wget -O /tmp/file http://example.com creates=/tmp/file only downloads the file if it doesn't exist.
Q07 of 08JUNIOR
How do you capture the output of a `command` or `shell` task?
ANSWER
Use the register keyword to store the result in a variable. The result includes stdout, stderr, rc (return code), and other fields. You can then use debug to print it: - command: whoami register: whoami_result then - debug: var=whoami_result.stdout_lines.
Q08 of 08SENIOR
What are the performance implications of using `shell` vs `command`?
ANSWER
command is faster because it doesn't spawn a shell process. For simple commands, the overhead is small, but in large playbooks with hundreds of tasks, using command where possible can reduce runtime. Additionally, shell may invoke profile scripts, adding latency.
01
What is the difference between Ansible's `command` and `shell` modules?
JUNIOR
02
How do you make a `command` or `shell` task idempotent?
JUNIOR
03
When would you use the `raw` module instead of `command` or `shell`?
SENIOR
04
What is the `script` module and when is it useful?
SENIOR
05
How can you prevent shell injection when using the `shell` module with user input?
SENIOR
06
What is the purpose of the `creates` flag in Ansible?
JUNIOR
07
How do you capture the output of a `command` or `shell` task?
JUNIOR
08
What are the performance implications of using `shell` vs `command`?
SENIOR
FAQ · 8 QUESTIONS
Frequently Asked Questions
01
Can I use pipes with the `command` module?
No. The command module does not invoke a shell, so pipes, redirects, and environment variable expansion are not supported. Use the shell module if you need these features.
Was this helpful?
02
Is the `raw` module idempotent?
No, by default. You can use creates or removes with raw in some cases, but since raw doesn't use Python, it's limited. For bootstrapping, this is acceptable as it runs once.
Was this helpful?
03
How do I run a command only if a file does not exist?
Use the `creates` flag:command: /usr/bin/touch /tmp/lock creates=/tmp/lock. This runs the command only if /tmp/lock does not exist.
Was this helpful?
04
What is the difference between `script` and `shell`?
script copies a local file to the remote host and executes it, while shell runs an inline command string. Use script for complex scripts that are easier to maintain as separate files.
Was this helpful?
05
Can I use `environment` with `command`?
Yes. The environment parameter sets environment variables for the command. It works with both command and shell modules.
Was this helpful?
06
How do I debug a command that fails on the remote host?
Add `register: result and debug: var=result.stdout_lines to see output. Also run ansible-playbook -vvv` to see the exact SSH command. Test the command manually on the target host with the same user.
Was this helpful?
07
What is the best way to install a package with Ansible?
Use the yum (or apt, dnf, pip) module, not shell. For example: yum: name=nginx state=present. It's idempotent and handles dependencies.
Was this helpful?
08
Can I use `async` with `command`?
Yes. Use async and poll to run long-running commands in the background. For example: command: /usr/bin/long_running async: 3600 poll: 30.