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
| Feature | script | copy + command |
|---|---|---|
| Steps | 1 | 2-3 |
| Script persists on remote | No (auto-cleaned) | Yes (unless you delete) |
creates/removes | ✅ | ✅ (on command) |
| Template variables | ❌ (use template + command) | ✅ (with template) |
| Idempotency | Via creates/removes | Via 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
| Issue | Solution |
|---|---|
| "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 files | Use chdir: to set working directory |
| Always shows "changed" | Add changed_when: false or use creates: |
| Variables not expanded | Script content isn't templated; pass as arguments or use environment |
| Windows line endings | Ensure LF not CRLF on Bash scripts |
Best Practices
- Use
creates/removesfor idempotency — skip if already done - Set
executable— don't rely on shebang or default shell - Use
changed_when— script module always reports changed - Pass secrets via environment — not command-line arguments
- Prefer modules over scripts — use
scriptwhen no module exists - 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.