I found it difficult to get into Ansible by reading the official documentation. This post is an attempt at addressing that by:

  • providing trimmed-down examples to focus on what’s essential
  • explaining the core concepts that are needed to understand what is going on
  • highlighting the important entry points in the documentation to go further

It is not a complete documentation! It only provides a minimal (hopefully solid) foundation to reduce the confusion and speed up further learning. It assumes a basic knowledge of YAML. You can look at Yaml Starter if you need a refresher.

This guide will also serve as a personal cheat sheet, so it may be updated in the future.

Quick links

Getting Started

Ansible is meant for system administration of remote hosts.

That means quite a few things need to be specified even for trivial setups - let’s say, ensuring the existence of a file on a single machine.

The Most Minimal Setup

In the most bare-bone (useful) case, Ansible needs:

  • A list of machines to apply operations to. Ansible calls this inventory. This is specified in INI or YAML format, usually called inventory.ini or inventory.yaml.
  • A way to connect to administered machines. Usually ssh, using whichever setup you prefer: by default Ansible simply runs ssh <hostname> at the beginning and relies on ssh to perform the actual authentication (key, password, etc.).
  • A way to gain privileges on administered machines. For example sudo or su. This has to be passwordless because it wouldn’t make sense to ask for a password on hundreds of machines.
  • A list of operations to apply. Ansible calls this a playbook. Always in YAML format, typically called playbook.yaml, but can be anything.

Concepts

At a very high conceptual level there are only two important YAML structures that everything revolves around:

  • A Task is something to do on a target machine. For example ensuring that a file exists with a particular content.
  • A Play is a list of Tasks that are executed in order.

A playbook is a file (not a data structure) that contains a list of Plays. It’s essentially a synonym for “list of Plays”. It’s what Ansible requires as input, so even if you have a single Task you still need to define a Play with that single Task.

Invocations

Here are some common invocations. See below for detail about concepts not introduced yet.

# Apply the configuration from 'playbook.yaml'
# This is the most common thing to do.
ansible-playbook -i inventory.ini playbook.yaml 

# Show all the groups of machines that have been defined 
# in inventory.ini, including implicit groups.
ansible-inventory -i inventory.ini --list

# List modules in a collection
# ansible-doc -l <namespace>.<collection>
ansible-doc -l ansible.builtin

# Show the documentation for a module
ansible-doc ansible.builtin.debug

Ansible Configuration

The structure expected in the yaml files is defined implicitly by Ansible’s parser. This section explains the essentials. All the details can be found in the playbook keywords documentation page.

Note: one difficulty with Ansible’s configuration files is that the expected type of their content (e.g. list of Play, list of Task) can only be inferred from how this file is used. For example, a playbook is a playbook only because it’s used as an argument to ansible-playbook: it’s difficult to infer the type of what’s in an Ansible YAML file by looking at its content. See section “Tasks Organization” below for how to deal with that in practice.

Play

A playbook is the top-level yaml file that is processed by the ansible-playbook command. It expects a list of yaml mappings, each with the structure of Play. Assuming to have “myhosts” defined in your inventory, the example playbook below prints three messages, in the order they appear in the file:

- name: Play 1
  hosts: myhosts
  tasks:
    - name: Print a message
      ansible.builtin.debug:
        msg: "Hello world"
    - name: Print another message
      ansible.builtin.debug:
        msg: "Hello world, again"
        
- name: Play 2
  hosts: myhosts
  tasks:
    - name: Print a message
      ansible.builtin.debug:
        msg: "Hello world from play 2"

Often-used keys of a Play

  • name (string): a description meant for the user. Optional, but necessary in practice to make sense of Ansible’s logs.
  • tasks: a list of things to do, with the structure of Task.
  • hosts: which hosts to apply the tasks to.
  • become_method, become, become_user: specifying which user the tasks should be run as by default. If not specified, it’s the user that was used to connect to the hosts.
  • vars: mapping of arbitrary key/value pairs. The values can then be injected in tasks defined as part of this Play. See section “Variables” below.

Tasks

The tasks key in a Play is a list of Tasks, each specifying something to be done on the target host, or a state to be reached. Ansible is meant to run only tasks when necessary: if the state on the target is already correct, nothing is done. For example, if the task is to copy a file to the target host, if the file is already present with the right content, nothing is performed.

Tasks have a return value which can be accessed by other tasks specified after in the same list of tasks, if that return value has been given a name using the register key (see below).

Often-used keys of Task

  • name (string): description meant for the user, but that can also be used to select which task(s) to run through ansible-playbook.
  • <name of a module>: defines what the task does. For example ansible.builtin.debug (see example above). The value is a mapping with a structure specific to this module type, which defines precisely. what the module is supposed to do. This key is confusingly named ‘action’ in the documentation, with almost no hint that what is meant there is the name of the module itself instead of the word ‘action’.
  • vars (arbitrary key-value pairs): same meaning as for Play.
  • become_method, become, become_user: (optionally) specifies which user the tasks should be run as. If not specified, it’s the user that was used to connect to the hosts. Overrides any values specified at Play level.
  • register: name under which this task’s return value can be subsequently accessed (see section “Variables” below)
  • when: make it possible to skip the task depending on the output of a conditional expression.

Ansible ships with a lot of modules by default, you may want to start by looking at the ansible.builtin collection first.

Each module name is composed of three parts: <namespace>.<collection>.<module_name>, which is also how the documentation is organized.

Variables

Overview

Variables are the Ansible way of writing more generic configuration. A common way of defining variables is through the vars object.

Quick example (as a playbook):

- name: Copy Latex files
  hosts: myhosts
  vars:
    basename: "hello"
  tasks:
    - name: Copy .tex file
      ansible.builtin.debug:
        msg: "Copying {{ basename }}.tex"
    - name: Copy .pdf file
      ansible.builtin.debug:
        msg: "Copying {{ basename }}.pdf"

The ‘vars’ key is present on all structures that can contain other tasks: Play, Role, Block, Task. A variable is available to all tasks defined lower in the hierarchy.

Another way of defining a variable is through register, which gives a name to a task’s return value.

Example (this is a list of tasks, not a playbook):

- name: "copy existing file"
  vars:
    filename: "/some/local/file"
  block:
    - ansible.builtin.stat:
        path: "{{ filename }}"
      become: false
      delegate_to: localhost
      # Defines file_stat
      register: file_stat
    - ansible.builtin.copy:
        src: "{{ filename }}"
        dest: "/dest/dir/on/target"
        owner: "root"
        group: "root"
        mode: '0644'
      # Use file_stat here
      when: file_stat.stat.exists

Note: the variable filename could be defined at a higher level, to make the block of tasks fully reusable.

What this example does is:

  • runs ansible.builtin.stat on the local machine, to check whether ‘filename’ exists. the register key makes it save the return value into a variable called file_stat. The return value is a mapping documented on the ansible.builtin.stat documentation page.

  • runs ansible.builtin.copy conditionally, based on the value of file_stat.stat.exists, through the when attribute. If that value is truthy, the task is run, otherwise it is ignored.

The above code is thus similar to the following Python code (but Ansible does much more than that)

filename = "/some/local/file"
file_stat = os.path.exists(filename)
if file_stat:
  copy_to_remote_host(filename, "/dest/dir/on/target", remote_host=...)

Magic variables

Ansible automatically defines a lot of variables (in a way similar to make). This is documented on the page about special variables.

The only commonly useful variable is playbook_dir: path to the directory containing the playbook that is being run (i.e. the one passed as argument to the ansible-playbook binary). It allows referencing local files easily.

Tasks organization

While technically you could put all the tasks into a single play inside a single playbook, that just doesn’t work for complex setups. There are many different way of grouping tasks together in a meaningful way, and sharing them between plays.

Ways of writing tasks

‘name’ is optional: you don’t have to name your tasks, which is sometimes handy when the list of tasks is the interesting thing, or the task cannot fail (e.g. debug message), or when you include a list of tasks like below.

Example playbook (see below for more detail about builtin.import_tasks):

- name: Print some messages
  hosts: myhost
  tasks:
    - ansible.builtin.debug:
        msg: "Before running other tasks"
    - ansible.builtin.import_tasks: "{{ playbook_dir }}/tasks/other_tasks.yaml"
    - ansible.builtin.debug:
        msg: "After running other tasks"

Tasks can be grouped using the special block task: sometimes there are tasks that conceptually go together and for which it’s worth having some common setup (e.g. variables). This is what the block task provides. In particular makes it possible to name a group of tasks.

Example playbook

- name: Print some messages
  hosts: myhost
  tasks:
    - name: display several messages
      block: 
        - ansible.builtin.debug:
            msg: "message 1"
        - ansible.builtin.debug:
            msg: "message 2"
    - name: one more message
      ansible.builtin.debug:
        msg: "another message"

Often-used keys for a Task with a block action:

  • some keys also present in Task: name, vars, become*, when. But not register.
  • a block can also be specified using the word always instead of block. In that case all tasks in the group are run regardless of errors.

Using multiple files

We list here some common options.

Use completely separate playbooks

Useful when you have orthogonal tasks to run, that do not share anything. For example, you could have a playbook to do some initial setup for Ansible (run once), then another one to do the long-term configuration.

Import plays from another playbook

Use the ansible.builtin.import_playbook module to include all the plays from a playbook into another one.

The two playbooks below are exactly equivalent to the playbook at the beginning of section ‘Play’ above:

# file: playbook.yaml
- name: Play 1
  hosts: myhosts
  tasks:
    - name: Print a message
      ansible.builtin.debug:
        msg: "Hello world"
    - name: Print another message
      ansible.builtin.debug:
        msg: "Hello world, again"

- ansible.builtin.import_playbook: included.yaml
# file: included.yaml
- name: Play 2
  hosts: myhosts
  tasks:
    - name: Print a message 2
      ansible.builtin.debug:
        msg: "Hello world from play 2"

Import tasks from another file

Use the ansible.builtin.import_tasks module to import a list of Tasks instead of a list of Plays. It makes it possible to write a list of tasks in a separate file, instead of having them in the playbook itself. This is a good way of sharing lists of tasks between plays, especially when combined with variables.

Example:

# file: playbook.yaml
- name: Play 1
  hosts: myhosts
  tasks:
    - name: Print a message
      ansible.builtin.debug:
      msg: "Hello world"
    - name: Print another message
      ansible.builtin.debug:
      msg: "Hello world, again"

This can be replaced by:

# file: playbook.yaml
# Contains a list of Play
- name: Play 1
  hosts: myhosts
  tasks:
    - name: execute mytasks
      ansible.builtin.import_tasks: "{{ playbook_dir }}/tasks/mytasks.yaml"
# file: tasks/mytasks.yaml
# Contains a list of Task. This is not a playbook!
- name: Print a message
    ansible.builtin.debug:
    msg: "Hello world"
- name: Print another message
    ansible.builtin.debug:
    msg: "Hello world, again"

The content of mytasks.yaml is literally the same as in the original playbook.yaml file, just dedented.

playbook_dir is a special variable automatically defined by Ansible, that contains the path to the playbook being executed (see the Variables section).

Gotcha: The above is a good example of how it’s difficult to infer the nature of a file just by inspecting the content. The file playbook.yaml is a playbook mainly because it’s used as an argument to ansible-playbook, a list of tasks is a list of tasks only because the file is used as argument to the import_tasks module.

For this reason, being disciplined about where files live is essential: follow Ansible recommended directory layout, or a simplified version of it. At the very least group files by type of content: tasks/, handlers/, templates/, files/, etc. so you know right away how to interpret each file just by looking at the directory name.

For the nitpicky out there: yes, you can infer what the content of a file by looking at its content, most of the time. In practice it’s much easier to look at the file path than the content itself.