Introduction
Ansible has three modules for running commands on remote hosts: command, shell, and raw. Each has different capabilities and security implications. Using shell when command works, or command when a dedicated module exists, leads to less secure and less idempotent playbooks.
Quick Comparison
| Feature | command | shell | raw |
|---|---|---|---|
| Shell processing | ❌ No | ✅ Yes | ✅ Yes |
Pipes | | ❌ No | ✅ Yes | ✅ Yes |
Redirects > < | ❌ No | ✅ Yes | ✅ Yes |
Env variables $HOME | ❌ No | ✅ Yes | ✅ Yes |
Glob *.log | ❌ No | ✅ Yes | ✅ Yes |
| Requires Python | ✅ Yes | ✅ Yes | ❌ No |
creates/removes | ✅ Yes | ✅ Yes | ❌ No |
chdir | ✅ Yes | ✅ Yes | ❌ No |
| Security | ✅ Safest | ⚠️ Shell injection risk | ⚠️ Shell injection risk |
| Idempotent by default | ❌ No | ❌ No | ❌ No |
command — Safe Default
command runs a command directly without a shell. No pipes, redirects, or shell features.
# ✅ Use command for simple, safe operations
- name: Check uptime
ansible.builtin.command: uptime
changed_when: false
- name: Create user
ansible.builtin.command: useradd -m -s /bin/bash deploy
register: result
changed_when: result.rc == 0
failed_when:
- result.rc != 0
- "'already exists' not in result.stderr"
# Use creates/removes for idempotency
- name: Extract archive (only if not already extracted)
ansible.builtin.command:
cmd: tar xzf /tmp/app.tar.gz -C /opt/app
creates: /opt/app/bin/start.sh
# ❌ FAILS — no shell processing
- name: This won't work
ansible.builtin.command: cat /var/log/*.log | grep ERROR
# Error: No such file or directory: '*.log'
shell — When You Need Shell Features
shell runs through /bin/sh. Use only when you need pipes, redirects, or shell expansion.
# ✅ Pipes require shell
- name: Count error lines
ansible.builtin.shell: grep -c ERROR /var/log/app.log || true
register: error_count
changed_when: false
# ✅ Redirects require shell
- name: Export database
ansible.builtin.shell: >
pg_dump mydb | gzip > /backup/mydb-$(date +%Y%m%d).sql.gz
args:
creates: "/backup/mydb-{{ ansible_date_time.date | replace('-', '') }}.sql.gz"
# ✅ Environment variables and subshells
- name: Check disk usage
ansible.builtin.shell: |
used=$(df -h / | awk 'NR==2 {print $5}' | tr -d '%')
if [ "$used" -gt 90 ]; then
echo "CRITICAL: ${used}% used"
exit 1
fi
echo "OK: ${used}% used"
register: disk_check
changed_when: false
failed_when: disk_check.rc != 0
# ✅ Glob patterns
- name: Remove old logs
ansible.builtin.shell: rm -f /var/log/app/*.log.gz
args:
removes: /var/log/app/*.log.gz
Shell Injection Warning
# ❌ DANGEROUS — user input in shell
- ansible.builtin.shell: "echo {{ user_input }}"
# If user_input is "; rm -rf /" → disaster!
# ✅ SAFE — use command (no shell interpretation)
- ansible.builtin.command: "echo {{ user_input }}"
# Treated as a literal argument
# ✅ SAFE — use quote filter if shell is required
- ansible.builtin.shell: "echo {{ user_input | quote }}"
raw — No Python Required
raw sends a command over SSH without Python. Use only for bootstrapping or network devices.
# ✅ Bootstrap Python on a fresh server
- name: Install Python (no Python on target yet)
ansible.builtin.raw: apt-get install -y python3
become: true
# ✅ Network devices without Python
- name: Show running config
ansible.builtin.raw: show running-config
register: config
# ✅ Minimal systems
- name: Check if Python exists
ansible.builtin.raw: which python3
register: python_check
failed_when: false
changed_when: false
Decision Flowchart
Need Python on target?
├─ No → raw
└─ Yes ↓
Need pipes, redirects, globs, or env vars?
├─ No → command (safest)
└─ Yes → shell
Actually... is there a dedicated module?
├─ Yes → USE THE MODULE instead
│ apt, yum, copy, template, service, user, file, etc.
└─ No → command or shell
Prefer Dedicated Modules
# ❌ Using shell/command when a module exists
- ansible.builtin.shell: apt-get install -y nginx
- ansible.builtin.command: systemctl restart nginx
- ansible.builtin.shell: useradd -m deploy
- ansible.builtin.shell: cp /tmp/config /etc/app/config
# ✅ Use dedicated modules — idempotent, cross-platform
- ansible.builtin.apt:
name: nginx
state: present
- ansible.builtin.service:
name: nginx
state: restarted
- ansible.builtin.user:
name: deploy
state: present
- ansible.builtin.copy:
src: /tmp/config
dest: /etc/app/config
remote_src: true
Make Commands Idempotent
# Always add changed_when for read-only commands
- ansible.builtin.command: cat /etc/hostname
changed_when: false
# Use creates/removes for file-producing commands
- ansible.builtin.command:
cmd: /opt/build.sh
creates: /opt/app/build/output.jar
# Use changed_when based on output
- ansible.builtin.shell: /opt/app/migrate.sh
register: migrate
changed_when: "'Applied' in migrate.stdout"
Related Articles
- Ansible shell Module
- Ansible changed_when / failed_when
- Ansible command vs shell
- Ansible Troubleshooting
Conclusion
command is the safe default — no shell injection risk. shell only when you need pipes, redirects, or glob expansion. raw only for bootstrapping Python or managing devices without Python. But before reaching for any of them, check if a dedicated Ansible module exists — modules are idempotent, cross-platform, and self-documenting. When you must use command/shell, always add changed_when and consider creates/removes for idempotency.