Introduction

ansible.builtin.shell executes commands through the shell (/bin/sh) on remote hosts. Unlike the command module, shell supports pipes, redirects, environment variables, and globbing. Use it when you need shell features — but prefer dedicated modules when possible.

Basic Usage

- name: Check disk usage
  ansible.builtin.shell: df -h | grep /dev/sda1
  register: disk_info

- name: Show result
  debug:
    msg: "{{ disk_info.stdout }}"

Parameters

ParameterDefaultDescription
cmd—Command string to execute
chdir—Change to directory before running
creates—Skip if this file exists
removes—Skip if this file doesn't exist
executable/bin/shShell to use
stdin—Data to pipe to stdin
stdin_add_newlinetrueAdd newline to stdin
warntrueShow warnings for common mistakes

Shell Features

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 -size +100M -exec ls -lh {} \;

- name: Get top CPU processes
  ansible.builtin.shell: ps aux --sort=-%cpu | head -5

Redirects

- name: Save output to file
  ansible.builtin.shell: dmesg > /tmp/dmesg.log 2>&1

- name: Append to log
  ansible.builtin.shell: echo "Deployed at $(date)" >> /var/log/deploy.log

Environment Variables

- name: Run with env vars
  ansible.builtin.shell: echo $MY_VAR
  environment:
    MY_VAR: hello
    PATH: "/opt/myapp/bin:{{ ansible_env.PATH }}"

# Export and use
- name: Source profile and run
  ansible.builtin.shell: source /etc/profile.d/myapp.sh && myapp --status
  args:
    executable: /bin/bash

Globbing

- name: Remove temp files
  ansible.builtin.shell: rm -f /tmp/myapp-*.log

- name: Archive logs
  ansible.builtin.shell: tar czf /backup/logs-$(date +%Y%m%d).tar.gz /var/log/myapp/*.log

Idempotent Patterns

creates / removes

# Only run if file doesn't exist
- name: Build application
  ansible.builtin.shell: make && make install
  args:
    chdir: /opt/src/myapp
    creates: /usr/local/bin/myapp

# Only run if file exists
- name: Clean build artifacts
  ansible.builtin.shell: make clean
  args:
    chdir: /opt/src/myapp
    removes: /opt/src/myapp/build

changed_when / failed_when

# Control changed status
- name: Check if service running
  ansible.builtin.shell: systemctl is-active myapp
  register: service_check
  changed_when: false
  failed_when: false

- name: Restart if not running
  ansible.builtin.service:
    name: myapp
    state: started
  when: service_check.rc != 0

# Custom changed detection
- name: Apply database migrations
  ansible.builtin.shell: python manage.py migrate --check
  args:
    chdir: /opt/myapp
  register: migration
  changed_when: "'No migrations to apply' not in migration.stdout"

shell vs command

Featureshellcommand
Pipes (|)✅❌
Redirects (>, >>)✅❌
Environment variables ($VAR)✅❌
Globbing (*.log)✅❌
Semicolons (;)✅❌
Shell injection risk⚠️ Higher✅ Lower
SpeedSlightly slowerSlightly faster

Rule: Use command when you don't need shell features. Use shell only when you need pipes, redirects, or glob.

# command is safer for simple commands
- ansible.builtin.command: uptime

# shell needed for pipes
- ansible.builtin.shell: cat /etc/passwd | grep deploy

Multiline Commands

# Using YAML literal block
- name: Complex script
  ansible.builtin.shell: |
    cd /opt/myapp
    git pull origin main
    pip install -r requirements.txt
    python manage.py migrate
    systemctl restart myapp
  args:
    executable: /bin/bash

# Using heredoc
- name: Database operations
  ansible.builtin.shell: |
    psql -U postgres <<EOF
    CREATE DATABASE IF NOT EXISTS myapp;
    GRANT ALL PRIVILEGES ON DATABASE myapp TO deploy;
    EOF

Register and Parse Output

- name: Get memory info
  ansible.builtin.shell: free -m | awk '/Mem:/ {print $3}'
  register: memory_used
  changed_when: false

- name: Alert on high memory
  debug:
    msg: "WARNING: {{ memory_used.stdout }}MB used"
  when: memory_used.stdout | int > 1024

# Parse JSON output
- name: Get container info
  ansible.builtin.shell: docker inspect myapp
  register: container_info
  changed_when: false

- set_fact:
    container_ip: "{{ (container_info.stdout | from_json)[0].NetworkSettings.IPAddress }}"

Practical Examples

Application Deployment

- name: Deploy application
  ansible.builtin.shell: |
    set -e
    cd /opt/myapp
    git fetch origin
    git checkout {{ app_version }}
    pip install -r requirements.txt
    python manage.py collectstatic --noinput
    python manage.py migrate --noinput
  args:
    executable: /bin/bash
  register: deploy
  notify: Restart app

Log Rotation

- name: Rotate and compress logs
  ansible.builtin.shell: |
    cd /var/log/myapp
    for f in *.log; do
      if [ -f "$f" ] && [ $(stat -f%z "$f" 2>/dev/null || stat -c%s "$f") -gt 104857600 ]; then
        mv "$f" "${f}.$(date +%Y%m%d)"
        gzip "${f}.$(date +%Y%m%d)"
      fi
    done
  args:
    creates: /var/log/myapp/.rotated

Health Check Script

- name: Run health checks
  ansible.builtin.shell: |
    errors=0
    # Check web server
    curl -sf http://localhost:{{ app_port }}/health || errors=$((errors+1))
    # Check database
    pg_isready -h localhost -p 5432 || errors=$((errors+1))
    # Check disk space
    [ $(df --output=pcent / | tail -1 | tr -d ' %') -lt 90 ] || errors=$((errors+1))
    
    echo "$errors errors found"
    exit $errors
  register: health
  failed_when: health.rc > 0
  changed_when: false

Security Considerations

# DANGEROUS — shell injection risk
- shell: "echo {{ user_input }}"    # If user_input contains "; rm -rf /"

# SAFER — use quote filter
- shell: "echo {{ user_input | quote }}"

# SAFEST — use command module or dedicated modules
- command: "echo {{ user_input }}"
- user: { name: "{{ username }}", state: present }

Troubleshooting

Command Not Found

# Specify full path
- shell: /usr/local/bin/myapp --status

# Or set PATH
- shell: myapp --status
  environment:
    PATH: "/usr/local/bin:{{ ansible_env.PATH }}"

# Use bash for source/bashrc
- shell: source ~/.bashrc && nvm use 18 && node app.js
  args:
    executable: /bin/bash

Non-Zero Exit Code

# Ignore errors
- shell: some_command
  ignore_errors: true

# Custom failure detection
- shell: grep "ERROR" /var/log/app.log
  register: errors
  failed_when: false
  changed_when: false

Conclusion

Use ansible.builtin.shell when you need shell features — pipes, redirects, globbing, or environment variables. For simple commands, prefer command (safer, no shell injection risk). Always use changed_when and failed_when for idempotency. Use creates/removes to skip unnecessary runs. Quote user input with | quote to prevent injection. Prefer dedicated Ansible modules over shell commands whenever possible — they're idempotent by design and produce better output.