Skip to main content
RunBook Academy

AnsibleIX · command, shell and rawcommand, shell and raw

script: transferring a script and running it

Intermediate⏱ ~15 minansible-playbookansible-doc

What you'll learn

  • Describe what script does that copy plus command does not, and vice versa
  • Use creates, removes, chdir and executable with script correctly
  • Explain why script needs no Python on the managed node
  • State what check mode and ansible-lint can and cannot see inside a script
  • Decide when a script is genuinely the right answer

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

ansible.builtin.script takes a script that lives on the controller, transfers it to the managed node, runs it, and cleans up after itself.

It sits between shell and a proper module in almost every dimension: more capable than a one-line command, less inspectable than a module, and — importantly for a course organised around blast radius — the point at which the tooling stops being able to tell you what a task will do.

What it does that copy plus command does not

The obvious alternative is two tasks: copy the script, then run it. The differences are real and cut both ways.

scriptcopy + command
Script left on the managed node afterwardsnoyes, until you remove it
Requires Python on the targetnoyes, for copy
creates / removes guardsyeson the command half
File mode, owner, SELinux context managednoyes, by copy
Idempotent on its ownnocopy is; command is not
stdout and stderr separated in the resultno — see belowyes
Vaulted script decrypted automaticallyyesyes

The “no Python required” row is the one people forget. script shares that property with rawansible-doc records the same platform note for both — which makes it the natural second step in a bootstrap sequence, when raw has got you a shell but not yet an interpreter.

Writing the task

Configuration changethe free-form and the explicit forms
- name: Run the vendor preflight checker
ansible.builtin.script: files/vendor-preflight.sh --strict

- name: Same task, options spelled out
ansible.builtin.script:
  cmd: files/vendor-preflight.sh --strict
  chdir: /opt/vendor
  creates: /opt/vendor/.preflight-passed
  executable: /bin/bash

The documented options are cmd, chdir, creates, removes, executable and the free-form form, and that is the complete list. There is no mode, no owner, no argv.

Two behaviours worth naming:

  • executable names the interpreter to invoke the script with. If the script has a shebang and is being run through the remote shell, you rarely need it; set it when you want to be certain which interpreter runs, or when the file has no shebang.
  • Arguments after the path are shell-interpreted on the target, the same as shell. Everything the injection lesson said applies here: a {{ var }} in the argument list needs | quote, and there is no argv escape hatch.

Guards work; idempotency does not come free

script supports creates and removes exactly as command does, and gets the same partial check-mode support from them. Everything else about change reporting is the same too: the task reports changed whenever the script ran.

Configuration changeguarding a one-shot migration
- name: Migrate the on-disk data format to v3
ansible.builtin.script:
  cmd: files/migrate-to-v3.sh
  creates: /var/lib/app/.format-v3

- name: Reconcile licences, reporting honestly
ansible.builtin.script: files/licence-reconcile.sh
register: licence
changed_when: licence.stdout is not search('already up to date')

The marker discipline matters more here than with command, because a script is usually doing several things. Write the marker as the script’s final action, after everything that could fail has succeeded. A marker written first turns a partial failure into a task that will never run again.

What you give up

This is the part that decides whether a script is the right answer.

Check mode sees nothing. The check-mode attribute is partial, and the detail is identical to command: the support consists of creates and removes. Under --check an unguarded script task is skipped, so a dry run tells you the script would have run and nothing about what it would have done.

ansible-lint sees nothing. It reads your YAML. The script is a file it does not parse, so every rule about deprecated modules, unquoted octal modes, become usage and shell safety stops applying at the task boundary. Whatever is in that file is unreviewed by the automation.

--diff sees nothing. The diff attribute is support: none. If the script rewrites /etc/app/main.conf, no diff appears in the output, because nothing in the pipeline knows a file was touched.

The result is unstructured. You get a return code and a combined output stream. Everything downstream is string matching.

When a script is genuinely right

Three cases hold up under review:

  1. A vendor installer or a proprietary CLI sequence. The logic is not yours, it is not idempotent, and rewriting it as modules would mean reimplementing a vendor’s product.
  2. A one-shot migration. It runs once per host in its life, it is guarded by creates, and its correctness is established by testing rather than by reasoning about convergence.
  3. A bootstrap step that must run before Python exists. raw gets you a shell; script gets you an arbitrarily long program without needing an interpreter on the target.

Blast radius

A script task ships an arbitrary program to every targeted host and runs it, usually with become: true, with no dry run and no diff. The blast radius is “whatever the script does”, and the only way to know what that is, is to read it.

Two controls are worth making habitual:

Read-only / Safebefore shipping a script to a fleet
# What is the actual host list?
ansible-playbook -i inventories/prod migrate.yml --limit dbservers --list-hosts

# Prove it on one host first. --check will not exercise the script at all.
ansible-playbook -i inventories/prod migrate.yml --limit db01.example.com --diff

The second line is not a formality. For a play whose real work is in a script task, a single-host run is the only dry run that exists.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A team replaces an ansible.builtin.script task with ansible.builtin.copy plus ansible.builtin.command. A failed_when matching on result.stderr suddenly starts working. Why did it not work before?

  2. Q2. Which of these does ansible.builtin.script give you? Select all that apply.

  3. Q3. Under --check, an ansible.builtin.script task with no creates or removes guard is skipped without running.

  4. Q4. A one-shot migration script writes its marker file as its first action, before doing any work. What is the failure this creates?

Passing score: 75%. Answers are checked in this browser.