Introduction

ansible.builtin.script transfers a script from the Ansible controller to remote hosts and executes it — all in one step. No need to copy the script first, no need for the script to exist on the remote machine. Ansible handles the transfer, execution, and cleanup automatically.

Basic Usage

---
- name: Script module examples
  hosts: all
  tasks:
    - name: Run a local Bash script remotely
      ansible.builtin.script: scripts/setup.sh

    - name: Run with arguments
      ansible.builtin.script: scripts/deploy.sh --version 2.5.1 --env production

    - name: Run a Python script
      ansible.builtin.script: scripts/health_check.py
      args:
        executable: /usr/bin/python3

With Arguments and Options

    - name: Run script with all options
      ansible.builtin.script:
        cmd: scripts/configure-app.sh --port 8080 --workers 4
        creates: /opt/app/.configured  # Skip if this file exists
        removes: /opt/app/.needs-setup  # Skip if this file is MISSING
        executable: /bin/bash
        chdir: /opt/app
      register: script_result
      become: true

    - name: Show script output
      ansible.builtin.debug:
        msg: "{{ script_result.stdout_lines }}"

Practical Examples

System Health Check

#!/bin/bash
# scripts/health-check.sh
echo "=== System Health Report ==="
echo "Hostname: $(hostname)"
echo "Uptime: $(uptime -p)"
echo "Load: $(cat /proc/loadavg | cut -d' ' -f1-3)"
echo "Memory: $(free -h | awk '/Mem:/ {printf "%s/%s (%.1f%%)", $3, $2, $3/$2*100}')"
echo "Disk /: $(df -h / | awk 'NR==2 {printf "%s/%s (%s)", $3, $2, $5}')"
echo "CPU cores: $(nproc)"

# Exit code indicates health
LOAD=$(cat /proc/loadavg | cut -d' ' -f1)
CORES=$(nproc)
if (( $(echo "$LOAD > $CORES * 2" | bc -l) )); then
    echo "STATUS: CRITICAL"
    exit 2
fi
echo "STATUS: OK"
exit 0
    - name: Run health check
      ansible.builtin.script: scripts/health-check.sh
      register: health
      changed_when: false
      failed_when: health.rc == 2

    - name: Alert on unhealthy hosts
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }}: {{ health.stdout_lines | last }}"

Application Setup Script

    - name: Run initial setup (only if not configured)
      ansible.builtin.script: scripts/initial-setup.sh
      args:
        creates: /opt/app/.initialized
      # Skips if /opt/app/.initialized already exists
      become: true

    - name: Run database migration
      ansible.builtin.script: scripts/migrate-db.py
      args:
        executable: /usr/bin/python3
      environment:
        DATABASE_URL: "{{ vault_db_url }}"
      run_once: true
      no_log: true

Collect Diagnostics

    - name: Collect server diagnostics
      ansible.builtin.script: scripts/collect-diag.sh
      register: diagnostics
      changed_when: false

    - name: Save diagnostics locally
      ansible.builtin.copy:
        content: "{{ diagnostics.stdout }}"
        dest: "/tmp/diag-{{ inventory_hostname }}.txt"
      delegate_to: localhost

PowerShell on Windows

- name: Configure Windows servers
  hosts: windows
  tasks:
    - name: Run PowerShell setup
      ansible.builtin.script: scripts/setup.ps1
      args:
        executable: powershell.exe

    - name: Run with parameters
      ansible.builtin.script: scripts/configure-iis.ps1 -SiteName "MyApp" -Port 8080
      args:
        executable: powershell.exe

script vs copy + command

    # Using script (one step):
    - name: Run setup
      ansible.builtin.script: scripts/setup.sh

    # Equivalent using copy + command (two steps):
    - name: Copy script
      ansible.builtin.copy:
        src: scripts/setup.sh
        dest: /tmp/setup.sh
        mode: '0755'

    - name: Run script
      ansible.builtin.command: /tmp/setup.sh

    - name: Clean up
      ansible.builtin.file:
        path: /tmp/setup.sh
        state: absent
Featurescriptcopy + command
Steps12-3
Script persists on remoteNo (auto-cleaned)Yes (unless you delete)
creates/removes✅✅ (on command)
Template variables❌ (use template + command)✅ (with template)
IdempotencyVia creates/removesVia creates/removes

Idempotency Patterns

    # Skip if already done
    - name: Initialize only once
      ansible.builtin.script: scripts/init.sh
      args:
        creates: /opt/app/.initialized

    # Skip if prerequisite is gone
    - name: Run migration if pending
      ansible.builtin.script: scripts/migrate.sh
      args:
        removes: /opt/app/.migration-pending

    # Custom idempotency with register
    - name: Check current state
      ansible.builtin.command: cat /opt/app/version
      register: current_version
      failed_when: false
      changed_when: false

    - name: Upgrade if needed
      ansible.builtin.script: scripts/upgrade.sh {{ target_version }}
      when: current_version.stdout | default('0.0.0') != target_version

Troubleshooting

IssueSolution
"Permission denied"Script needs execute bit locally; or use executable:
"Interpreter not found"Set executable: /usr/bin/python3 or /bin/bash
Script can't find filesUse chdir: to set working directory
Always shows "changed"Add changed_when: false or use creates:
Variables not expandedScript content isn't templated; pass as arguments or use environment
Windows line endingsEnsure LF not CRLF on Bash scripts

Best Practices

  1. Use creates/removes for idempotency — skip if already done
  2. Set executable — don't rely on shebang or default shell
  3. Use changed_when — script module always reports changed
  4. Pass secrets via environment — not command-line arguments
  5. Prefer modules over scripts — use script when no module exists
  6. Keep scripts small — large scripts are harder to debug remotely

Conclusion

ansible.builtin.script is the quickest way to run local scripts on remote hosts. One task handles transfer, execution, and cleanup. Use it for custom setup scripts, health checks, and one-off operations where no Ansible module exists. Add creates: for idempotency and executable: for explicit interpreter selection.