Skip to main content

Ansible Basics: Modules, Tasks, Plays and Playbooks

See what Ansible does on every run, what modules, collections, tasks, plays and playbooks are, and why running a playbook twice is safe. With real output.

In Part 1 you ran single commands on two servers. Before you write real automation, it helps to know what Ansible does with each command, and the handful of terms the rest of the guide uses: modules, collections, tasks, plays and playbooks. You also see the property that makes Ansible safe to rerun, idempotency, on real output.

Prerequisites

  • The ~/webfleet project from Part 1: ansible.cfg, inventory.ini, and SSH access to web1 and web2.
  • Run every command in this part from ~/webfleet.

What Happens When Ansible Runs a Task

For every task and every host, Ansible:

  1. works out the variables for that host from the inventory and your playbook;
  2. opens an SSH connection, or reuses one (Ansible keeps connections open for 60 seconds with SSH's ControlPersist);
  3. packs the module and the code it needs into one self-contained Python file, called AnsiballZ, and copies it to a temporary directory under ~/.ansible/tmp on the host;
  4. runs it with the host's Python, with sudo first if the task uses become;
  5. reads the JSON the module prints, removes the temporary files, and reports the result.

You can watch this with -vvv. These are the commands Ansible ran for one ping, with the long list of SSH options shortened to [options]:

ansible web1 -m ansible.builtin.ping -vvv
<192.168.56.11> SSH: EXEC ssh [options] 192.168.56.11 '/bin/sh -c '"'"'echo ~ubuntu'"'"''
<192.168.56.11> SSH: EXEC ssh [options] 192.168.56.11 '/bin/sh -c '"'"'( umask 77 && mkdir -p "` echo /home/ubuntu/.ansible/tmp `"&& mkdir "` echo /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080 `" && echo ansible-tmp-1792812560.610121-69-129449096889080="` echo /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080 `" )'"'"''
<192.168.56.11> PUT /home/dev/.ansible/tmp/ansible-local-62l2qgk7s9/tmpi_0z1x9j TO /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080/AnsiballZ_ping.py
<192.168.56.11> SSH: EXEC sftp -b - [options] '[192.168.56.11]'
<192.168.56.11> SSH: EXEC ssh [options] 192.168.56.11 '/bin/sh -c '"'"'chmod u+rwx /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080/ /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080/AnsiballZ_ping.py'"'"''
<192.168.56.11> SSH: EXEC ssh [options] 192.168.56.11 '/bin/sh -c '"'"'/usr/bin/python3 /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080/AnsiballZ_ping.py'"'"''
<192.168.56.11> SSH: EXEC ssh [options] 192.168.56.11 '/bin/sh -c '"'"'rm -f -r /home/ubuntu/.ansible/tmp/ansible-tmp-1792812560.610121-69-129449096889080/ > /dev/null 2>&1'"'"''

In order: find the home directory, create a private temporary directory, upload AnsiballZ_ping.py over SFTP, make it executable, run it with /usr/bin/python3, and delete the directory.

Three consequences follow from this design:

  • The managed nodes need Python (or you use the few modules that do not, such as raw), but no Ansible software and no running agent.
  • Each task costs a few SSH round trips. That is fine for tens of hosts; Part 9 shows the settings (pipelining, forks) that matter at hundreds.
  • A module's result is plain JSON with fields such as changed and failed. Ansible decides from them whether to report ok, changed or failed, and whether to notify handlers (Part 6).

Modules

A module is one unit of work with typed options: install a package, copy a file, create a user, start a service. You will use a small set most of the time:

Job Module
Packages ansible.builtin.apt, ansible.builtin.dnf, ansible.builtin.package
Files ansible.builtin.copy, ansible.builtin.template, ansible.builtin.file, ansible.builtin.lineinfile
Services ansible.builtin.service, ansible.builtin.systemd_service
Users ansible.builtin.user, ansible.builtin.group, ansible.posix.authorized_key
Commands ansible.builtin.command, ansible.builtin.shell (only when no module fits)
Checks ansible.builtin.uri, ansible.builtin.assert, ansible.builtin.debug

ansible-doc shows the documentation for any module installed on your control node, including examples, offline:

The full page explains every option and the return values; ansible-doc -s ansible.builtin.apt prints a task skeleton with all options instead. The same pages are online in the Ansible documentation.

Collections and Fully Qualified Names

Modules, plugins and roles are shipped in collections, named namespace.collection. ansible-core contains one, ansible.builtin. Everything else (community.general, ansible.posix, amazon.aws, kubernetes.core and hundreds more) you install from Ansible Galaxy with ansible-galaxy, which Part 8 covers.

ansible-galaxy collection list shows the collections installed from Galaxy. The built-in collection is part of ansible-core, so on this control node the list is still empty:

ansible-galaxy collection list

Refer to modules by their fully qualified collection name (FQCN), such as ansible.builtin.copy rather than copy. Short names still work for built-in modules, but with several collections installed a short name can resolve to a different module than you expect, and the FQCN tells the reader where to find the documentation. ansible-lint flags short names with its fqcn rule (Part 9).

Idempotency: Why Reruns Are Safe

Most modules are declarative: you describe the end state ("the package tree is installed"), and the module checks the current state first and only acts if it differs. Run the same module twice:

The first run reports CHANGED and the second only SUCCESS with "changed": false. That property, idempotency, is what lets you run the same playbook every day, or every hour from CI: hosts that are already right are left alone, and hosts that drifted are fixed.

The command and shell modules cannot do this, because they cannot know what a command does. They run every time and report changed every time:

ansible web1 -b -a "apt-get install -y tree"
web1 | CHANGED | rc=0 >>
Reading package lists...
Building dependency tree...
Reading state information...

Use a module whenever one exists. When you must use command or shell, tell Ansible when the command changes something with changed_when and creates/removes, which Part 3 shows.

Tasks, Plays and Playbooks

Ad-hoc commands run one module. For anything you want to repeat, write a playbook: a YAML file with a list of plays. Each play maps a group of hosts to a list of tasks, and each task calls one module with its options.

---
- name: Check the web servers # a play
  hosts: web # which inventory hosts or groups it runs on
  gather_facts: true # collect facts about each host first (the default)

  tasks:
    - name: Show the operating system # a task: a name, a module, its options
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} runs {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}"

    - name: Set the login message
      ansible.builtin.copy:
        content: |
          This server is managed by Ansible from the webfleet project.
          Manual changes may be overwritten.
        dest: /etc/motd
        owner: root
        group: root
        mode: '0644'
      become: true # only this task runs with sudo

Save it as hello.yml and run it with ansible-playbook:

ansible-playbook hello.yml
PLAY [Check the web servers] ***************************************************

TASK [Gathering Facts] *********************************************************
ok: [web2]
ok: [web1]

TASK [Show the operating system] ***********************************************
ok: [web1] => {
    "msg": "web1 runs Ubuntu 24.04"
}
ok: [web2] => {
    "msg": "web2 runs Ubuntu 24.04"
}

TASK [Set the login message] ***************************************************
changed: [web2]
changed: [web1]

PLAY RECAP *********************************************************************
web1                       : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2                       : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

How to read the output:

  • PLAY and TASK lines show where Ansible is. Tasks run in order, each on all hosts in parallel (5 hosts at a time by default, set with forks), before the next task starts.
  • Gathering Facts is the implicit first task: the setup module collects details about each host, such as its distribution, which the debug task uses through ansible_facts.
  • ok means the host was already in the wanted state; changed means the module changed something; failed would stop that host.
  • PLAY RECAP counts the results per host: ok, changed, unreachable, failed, skipped, rescued and ignored.

Run it again. Nothing changes, because /etc/motd already has the wanted content:

ansible-playbook hello.yml
PLAY RECAP *********************************************************************
web1                       : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0
web2                       : ok=3    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

A playbook can hold several plays, for example one for the web servers and one for the databases, and they run from top to bottom. Each play can set its own become, variables and options.

Preview Changes with Check Mode and Diff

Before you change production servers, ask Ansible what it would change. --check runs the modules in a dry-run mode that reports changes without making them, and --diff shows the changes to file contents. Edit the second line of the message in hello.yml, then:

ansible-playbook hello.yml --check --diff --limit web1
TASK [Set the login message] ***************************************************
--- before: /etc/motd
+++ after: /etc/motd
@@ -1,2 +1,2 @@
 This server is managed by Ansible from the webfleet project.
-Manual changes may be overwritten.
+Manual changes are overwritten on the next run.

changed: [web1]

PLAY RECAP *********************************************************************
web1                       : ok=3    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Nothing changed on web1: the recap counts the change that would happen. --limit web1 restricted the run to one host. Undo the edit in hello.yml before you go on, or the next real run applies it.

Check mode is only as good as the modules: most built-in modules support it, but modules that cannot predict their result, such as command, shell and uri, are skipped in check mode, and a task that depends on an earlier task's real result can fail. Treat a check run as a preview, not a guarantee.

Three more options are useful before a run:

YAML in Five Minutes

Playbooks, inventories and variable files are YAML. You need only a little of it, but the mistakes are easy to make:

# a mapping (key: value); a space after the colon is required
name: web1

# a list: one item per line, each starting with "- "
packages:
  - nginx
  - tree

# indentation (spaces only, never tabs) shows what belongs to what
nginx:
  port: 80
  user: www-data

# multi-line strings: | keeps line breaks, > folds them into spaces
motd: |
  line one
  line two

# a value that starts with {{ must be quoted, or YAML reads it as a mapping
greeting: '{{ inventory_hostname }}'

Two traps catch everyone once. First, YAML 1.1, which Ansible uses, reads yes, no, on and off (in any case) as booleans, so a country code NO becomes false:

ansible localhost -m ansible.builtin.debug -a "var=country" -e '{"country": NO}'
localhost | SUCCESS => {
    "country": false
}

Quote strings that could be read as something else ("NO", "0644", "1.10"), and write booleans as true and false. Second, a colon followed by a space inside an unquoted value starts a new mapping, so quote any string that contains ": ".

To see how a YAML parser reads tricky input step by step, try the YAML parsing simulator.

Common Mistakes

  • Using command or shell when a module exists. You lose idempotency, check mode and readable output. Search ansible-doc -l first.
  • Forgetting become. Tasks that write to /etc or install packages fail with a permission error. Set become: true on the task or the play.
  • Tabs or uneven indentation in YAML. The error points at a line near the problem, not always at it. An editor with YAML support, or ansible-playbook --syntax-check, catches most of these.
  • Short module names. Write ansible.builtin.copy, not copy.

Summary

  • For each task and host, Ansible connects over SSH, copies the module as one Python file, runs it and reads its JSON result.
  • Modules do the work; collections ship them; use fully qualified names like ansible.builtin.apt.
  • Declarative modules are idempotent: they act only when the current state differs, so reruns are safe. command and shell are not.
  • A playbook is a list of plays; a play maps hosts to tasks; a task calls one module.
  • --check --diff previews changes, and --syntax-check, --list-hosts and --list-tasks inspect a playbook without running it.

In Part 3 you write a real playbook that turns both servers into nginx web servers, with conditions, error handling and a test at the end.

Keep Learning

Updated:

Tags

AnsibleAutomationConfiguration ManagementInfrastructure as CodeDevOps

Found an issue?