Introduction
Ansible has two modules for running commands on remote hosts: command and shell. They look similar but behave differently. command runs a binary directly (safer, faster). shell runs through /bin/sh (supports pipes, redirects, environment variables). Choosing the wrong one is a common source of bugs.
The Key Difference
# command — runs the binary directly (no shell processing)
- ansible.builtin.command: ls /tmp/*.log
# FAILS — globbing (*.log) requires a shell
# shell — runs through /bin/sh (full shell features)
- ansible.builtin.shell: ls /tmp/*.log
# WORKS — shell expands *.log
| Feature | command | shell |
|---|---|---|
Pipes (|) | ❌ | ✅ |
Redirects (>, >>) | ❌ | ✅ |
Globbing (*.log) | ❌ | ✅ |
Environment variables ($HOME) | ❌ | ✅ |
Chained commands (&&, ;) | ❌ | ✅ |
Subshells ($(...)) | ❌ | ✅ |
| Shell injection risk | Low | Higher |
| Performance | Slightly faster | Slightly slower |
When to Use command
Use command for simple, single-binary execution:
---
- name: command module examples
hosts: all
tasks:
- name: Check disk space
ansible.builtin.command: df -h /
register: disk
- name: Run application binary
ansible.builtin.command: /opt/myapp/bin/start --port 8080
- name: Check service status
ansible.builtin.command: systemctl is-active nginx
register: nginx_status
ignore_errors: true
- name: Run with specific working directory
ansible.builtin.command:
cmd: ./manage.py migrate
chdir: /opt/app
- name: Run only if file doesn't exist
ansible.builtin.command: /opt/setup.sh
args:
creates: /opt/app/.installed
- name: Run only if file exists
ansible.builtin.command: /opt/cleanup.sh
args:
removes: /opt/app/tmp/cache
When to Use shell
Use shell when you need shell features:
---
- name: shell module examples
hosts: all
tasks:
# Pipes
- name: Count running processes
ansible.builtin.shell: ps aux | wc -l
register: proc_count
- name: Find large files
ansible.builtin.shell: find /var/log -name "*.log" -size +100M | head -10
register: large_files
# Redirects
- name: Export database
ansible.builtin.shell: pg_dump mydb > /backup/mydb.sql 2>&1
- name: Append to log
ansible.builtin.shell: echo "Deploy at $(date)" >> /var/log/deploys.log
# Globbing
- name: Remove old logs
ansible.builtin.shell: rm -f /var/log/myapp/*.log.gz
# Environment variables
- name: Use environment variable
ansible.builtin.shell: echo $HOME/.config/myapp
# Chained commands
- name: Build and install
ansible.builtin.shell: cd /opt/src && make && make install
# Subshell
- name: Get process ID
ansible.builtin.shell: kill $(cat /var/run/myapp.pid)
ignore_errors: true
Safer shell Usage
Avoid Shell Injection
# DANGEROUS — user input could contain shell metacharacters
- ansible.builtin.shell: "grep {{ user_input }} /etc/passwd"
# If user_input = "; rm -rf /" → disaster
# SAFER — use command module when possible
- ansible.builtin.command: "grep {{ user_input }} /etc/passwd"
# command doesn't process shell metacharacters
# SAFEST — use quote filter
- ansible.builtin.shell: "grep {{ user_input | quote }} /etc/passwd"
Use executable Parameter
# Use bash instead of /bin/sh (for bash-specific features)
- ansible.builtin.shell: |
shopt -s nullglob
files=(/opt/app/logs/*.log)
echo "${#files[@]} log files"
args:
executable: /bin/bash
Common Parameters (Both Modules)
- ansible.builtin.command:
cmd: /opt/app/start.sh # Command to run
chdir: /opt/app # Working directory
creates: /opt/app/.running # Skip if file exists
removes: /opt/app/.stopped # Skip if file doesn't exist
register: result # Capture output
changed_when: "'changed' in result.stdout" # Custom changed detection
failed_when: result.rc > 1 # Custom failure condition
ignore_errors: true # Don't fail the play
become: true # Run as root
environment: # Set environment variables
PATH: "/opt/app/bin:{{ ansible_env.PATH }}"
APP_ENV: production
Handling Output
- name: Run command and capture output
ansible.builtin.command: cat /etc/os-release
register: os_info
- name: Use the output
ansible.builtin.debug:
msg:
stdout: "{{ os_info.stdout }}"
stdout_lines: "{{ os_info.stdout_lines }}" # List of lines
stderr: "{{ os_info.stderr }}"
rc: "{{ os_info.rc }}" # Return code
changed: "{{ os_info.changed }}"
Idempotency (creates/removes)
Both modules are not idempotent by default — they run every time. Use creates or removes to make them conditional:
# Only runs if /opt/app doesn't exist
- ansible.builtin.command: /opt/install.sh
args:
creates: /opt/app
# Only runs if the lock file exists
- ansible.builtin.command: /opt/cleanup.sh
args:
removes: /tmp/myapp.lock
# Custom idempotency with changed_when
- ansible.builtin.shell: |
if [ -f /opt/app/.configured ]; then
echo "already configured"
else
/opt/configure.sh && touch /opt/app/.configured
echo "configured"
fi
register: config_result
changed_when: "'configured' == config_result.stdout_lines[-1]"
Better Alternatives
Before using command or shell, check if a dedicated module exists:
| Instead of... | Use... |
|---|---|
command: apt install nginx | ansible.builtin.apt: name=nginx |
command: systemctl restart nginx | ansible.builtin.service: name=nginx state=restarted |
command: useradd alice | ansible.builtin.user: name=alice |
shell: echo "text" > /tmp/file | ansible.builtin.copy: content="text" dest=/tmp/file |
shell: sed -i 's/old/new/' file | ansible.builtin.lineinfile or replace |
shell: curl https://... | ansible.builtin.get_url or uri |
command: mkdir -p /opt/app | ansible.builtin.file: state=directory |
command: cp /a /b | ansible.builtin.copy: remote_src=true |
Dedicated modules are idempotent, cross-platform, and provide better error handling.
Multiline Commands
# shell with multiline (using |)
- ansible.builtin.shell: |
cd /opt/app
source venv/bin/activate
python manage.py migrate
python manage.py collectstatic --noinput
args:
executable: /bin/bash
# command doesn't support multiline well — use shell for that
Troubleshooting
"No such file or directory" with command
Globbing or ~ doesn't work in command:
# FAILS
- ansible.builtin.command: ls ~/Documents/*.pdf
# FIX — use shell
- ansible.builtin.shell: ls ~/Documents/*.pdf
# OR — use full path without glob
- ansible.builtin.command: ls /home/alice/Documents/report.pdf
"[WARNING] Consider using apt/yum module rather than running command"
Ansible detects you're using command/shell for something a module handles better. Switch to the dedicated module.
"changed" on Every Run
Both modules always report changed: true. Fix with changed_when:
- ansible.builtin.command: /opt/app/check-status.sh
register: status
changed_when: false # This is a read-only check
Related Articles
- Ansible command Module
- Ansible shell Module
- Ansible script Module
- Ansible raw Module
- Ansible register Variables
Conclusion
Use command for simple binary execution — it's safer and faster. Use shell when you need pipes, redirects, globbing, or environment variables. Always check if a dedicated Ansible module exists first (apt, service, file, copy, etc.) — they're idempotent and cross-platform. Use creates/removes or changed_when to make command/shell tasks idempotent. When using shell with user input, always apply the | quote filter.