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

Featurecommandshellraw
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"

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.