Working with Ansible Playbooks – Tips & Tricks with Examples
Tags: ansible, devops, cloud, tutorial
Published: 2022-05-15 17:14:57

In this article, we are exploring Ansible Playbooks, which are basically blueprints for automation actions. Playbooks allow us to define a recipe with all the steps we would like to automate in a repeatable, simple, and consistent manner. 

If you are entirely new to Ansible, check out this introductory [Ansible Tutorial](https://spacelift.io/blog/ansible-tutorial) first.

## What is an Ansible Playbook?

Playbooks are one of the basic components of Ansible as they record and execute Ansible’s configuration. Generally, a playbook is the primary way to automate a set of tasks that we would like to perform on a remote machine. 

They help our automation efforts by gathering all the resources necessary to orchestrate ordered processes or avoid repeating manual actions. Playbooks can be reused and shared between persons, and they are designed to be human-friendly and easy to write in YAML.

## Playbook Structure

A playbook is composed of one or more plays to run in a specific order. A play is an ordered list of tasks to run against the desired group of hosts. 

Every task is associated with a module responsible for an action and its configuration parameters. Since most tasks are idempotent, we can safely rerun a playbook without any issue.

As discussed, playbooks are written in [YAML](https://docs.ansible.com/ansible/latest/reference_appendices/YAMLSyntax.html#yaml-syntax) using the standard extension .yml with minimal syntax. 

We must use spaces to align data elements that share the same hierarchy for indentation. Items that are children of other items must be indented more than their parents. There is no strict rule for the number of spaces used for indentation, but it’s pretty common to use two spaces while Tab characters are not allowed. 

Below is an example simple playbook with only two plays, each one having two tasks:

```
---
- name: Example Simple Playbook
  hosts: all
  become: yes

  tasks:
  - name: Copy file example_file to /tmp with permissions
    ansible.builtin.copy:
      src: ./example_file
      dest: /tmp/example_file
      mode: '0644'

  - name: Add the user 'bob' with a specific uid 
    ansible.builtin.user:
      name: bob
      state: present
      uid: 1040

- name: Update postgres servers
  hosts: databases
  become: yes

  tasks:
  - name: Ensure postgres DB is at the latest version
    ansible.builtin.yum:
      name: postgresql
      state: latest

  - name: Ensure that postgresql is started
    ansible.builtin.service:
      name: postgresql
      state: started
```

We define a **descriptive name** for each play according to its purpose on the top level. Then we represent the group of **hosts** on which the play will be executed, taken from the inventory. Finally, we define that these plays should be executed as the root user with the **become** option set to yes.

You can also define many other [Playbook Keywords](https://docs.ansible.com/ansible/latest/reference_appendices/playbooks_keywords.html) at different levels such as play, tasks, playbook to configure Ansible’s behavior. Even more, most of these can be set at runtime as command-line flags in the ansible configuration file, ansible.cfg, or the inventory. Check out the [precedence rules](https://docs.ansible.com/ansible/latest/reference_appendices/general_precedence.html#general-precedence-rules) to understand how Ansible behaves in these cases.

Next, we use the **tasks** parameter to define the list of tasks for each play. For each task, we define a clear and descriptive name. Every task leverages a module to perform a specific operation. 

For example, the first task of the first play uses the **ansible.builtin.copy** module. Along with the module, we usually have to define some **module arguments**. For the second task of the first play, we use the module ansible.builtin.user that helps us manage user accounts. In this specific case, we configure the name of the user, the state of the user account, and its uid accordingly. 

## Running a Playbook

When we are running a playbook, Ansible executes each task in order, one at a time, for all the hosts that we selected. This default behavior could be adjusted according to different use cases using strategies. 

If a task fails, Ansible stops the execution of the playbook to this specific host but continues to others that succeeded. During execution, Ansible displays some information about connection status, task names, execution status, and if any changes have been performed. 

At the end, Ansible provides a summary of the playbook’s execution along with failures and successes. Let’s see these in action by running the example playbook we saw earlier with the ansible-playbook command.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/6stg0o5qw7lu942o1711.png)

From the output, we notice the Play names, the Gathering Facts task, the Play tasks, and the Play Recap in the end. Since we didn’t define a databases hosts group, the second play of the playbook was skipped. 

We can use the **--limit** flag to limit the Playbook’s execution to specific hosts. For example:

```
ansible-playbook example-simple-playbook.yml --limit host1
```

##Using Variables in Playbooks

Variables are placeholders for values that you can reuse throughout a Playbook or other Ansible objects. They can only contain letters, numbers, and underscores and start with letters. 

Variables can be defined in Ansible in multiple levels, so look at [variable precedence](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#variable-precedence-where-should-i-put-a-variable) to understand how they are applied. For example, we can set variables at the global scope for all hosts, at the host scope for a particular host, or at the play scope for a specific play. 

To set host and group variables, create the directories **group_vars** and **host_vars**. For example, to define group variables for the **databases** group, create the file group_vars/databases. Set common default variables in a **group_vars/all** file.

Even more, to define host variables for a specific host, create a file with the same name as the host under the hosts_vars directory.

To substitute any variables during runtime, use the **-e** flag. 

The most straightforward method to define variables is to use a **vars** block at the beginning of a play. They are defined using standard YAML syntax.

```
- name: Example Variables Playbook
  hosts: all
  vars:
    username: bob
    version: 1.2.3
```

Another way is to define variables in external YAML files.

```
- name: Example Variables Playbook
  hosts: all
  vars_files:
    - vars/example_variables.yml
```

To use them in tasks, we have to reference them by placing their name inside double braces using the [Jinja2 syntax](https://docs.ansible.com/ansible/latest/user_guide/playbooks_templating.html):

```
- name: Example Variables Playbook
  hosts: all
  vars:
    username: bob

  tasks:
  - name: Add the user {{ username }}
    ansible.builtin.user:
      name: "{{ username }}"
      state: present
```


If a variable’s value starts with curly braces, [we must quote the whole expression](https://docs.ansible.com/ansible/latest/user_guide/playbooks_variables.html#when-to-quote-variables-a-yaml-gotcha) to allow YAML to interpret the syntax correctly. 

We can also define variables with multiple values as lists.

```
package:
  - foo1
  - foo2
  - foo3
```

It’s also possible to reference individual values from a list. For example, to select the first value foo1:

```
package: "{{ package[0] }}"
```

Another possible option is to define variables using YAML dictionaries. For example:

```
dictionary_example: 
  - foo1: one
  - foo2: two
```

Similarly, to get the first field from the dictionary:

```
dictionary_example['foo1']
```

To reference nested variables, we have to use a bracket or dot notation. For example, to get the example_name_2 value from this structure:

```
vars:
  var1:
    foo1:
      field1: example_name_1
      field2: example_name_2

tasks:
- name: Create user for field2 value
  user: 
    name: "{{ var1['foo1']['field2'] }}"
```

We can create variables using the register statement that captures the output of a command or task and then use them in other tasks.

```
- name: Example-2 Variables Playbook
  hosts: all

  tasks:
  - name: Run a script and register the output as a variable
    shell: "find example_file"
    args:
      chdir: "/tmp"
    register: example_script_output

  - name: Use the output variable of the previous task
    debug:
      var: example_script_output
```

## Handling Sensitive Data

At times, we would need to access sensitive data (API keys, passwords, etc.) in our playbooks. Ansible provides Ansible Vault to assist us in these cases. Storing them as variables in plaintext is considered a security risk so we can use the **ansible-vault** command to encrypt and decrypt these secrets.

After the secrets have been encrypted with a password of your choice, you can safely put them under source control in your code repositories. Ansible Vault protects only data at rest. After the secrets are decrypted, it’s our responsibility to handle them with care and not accidentally leak them. 

We have the option to encrypt variables or files. Encrypted variables are decrypted on-demand only when needed, while encrypted files are always decrypted as Ansible doesn’t know in advance if it needs content from them. 

In any case, we need to think about how are we going to [manage our vault passwords](https://docs.ansible.com/ansible/latest/user_guide/vault.html#managing-vault-passwords). To define encrypted content, we add the **!vault** tag, which tells Ansible that the content needs to be decrypted and the | character before our multi-line encrypted string.

To create a new encrypted file:

```
ansible-vault create new_file.yml
```

Then, an editor is launched to add our content to be encrypted. It’s also possible to encrypt existing files with the **encrypt** command:

```
ansible-vault encrypt existing_file.yml
```

To view an encrypted file:

```
ansible-vault view existing_file.yml
```

To edit an encrypted file in place, use the **edit** command to decrypt the file temporarily:

```
ansible-vault edit existing_file.yml
```

To use a different password on an encrypted file, use the **rekey** command by using the original password:

```
ansible-vault rekey existing_file.yml
```

In case you need to decrypt a file, you can do so with the **decrypt** command:

```
ansible-vault decrypt existing_file.yml
```

Similarly, we use the **encrypt_string** command to encrypt individual strings that we can use later in variables and include them in playbooks or variables files:

```
ansible-vault encrypt_string <password_source> '<string_to_encrypt>' –'<variable_name>'
```

For example, to encrypt the db_password string ‘12345679’ using the ansible vault:


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/u8ifeerby4w72wp1cljf.png)

Since we omitted the <password_source>, we manually entered the Vault password. This could also be achieved by passing a password file with **--vault-password-file**.

To view the contents of the above example encrypted variable that we saved in the vars.yml file, use the same password as before with the **--ask-vault-pass** flag:

```
ansible localhost -m ansible.builtin.debug -a var="db_password" -e "@vars.yml" --ask-vault-pass

Vault password:

localhost | SUCCESS => {
    "changed": false,
    "db_password": "12345678"
}
```

For managing multiple passwords, use the option **--vault-id** to set a label. For example, to set the label dev on a file and prompt for a password to use:

```
ansible-vault encrypt existing_file.yml --vault-id dev@prompt
```

To suppress output from a task that might log a sensitive value to the console, we use the no_log: true attribute:

```
tasks:
- name: Hide sensitive value example
  debug:
    msg: "This is sensitive information"
  no_log: true
```

If we run this task we will notice that the message isn’t printed on the console:

```
TASK [Hide sensitive value example] ***********************************
ok: [host1]
```

Finally, let’s use the example encrypted variable we created above in a playbook and execute it.


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/3dbgz792118jpba1h4mv.png)


![Image description](https://dev-to-uploads.s3.amazonaws.com/uploads/articles/ub5wve9kstkcu2zd7eeq.png)

Nice, we verified that we could decrypt the value successfully and use it in tasks.

## Triggering tasks on change with Handlers

In general, Ansible modules are idempotent and can be executed safely multiple times, but there are cases where we would like to run a task only when a change is made on the host. For example, we would like to restart a service only when updating its configuration files. 

Ansible uses handlers triggered when notified by other tasks to solve this use case. Tasks only notify their handlers, with the **notify**: parameter, when the tasks actually change something. 

Handlers should have globally unique names, and it’s common to author them at the bottom of the playbooks.

```
- name: Example with handler - Update apache config
  hosts: webservers
  
  tasks:
  - name: Update the apache config file
    ansible.builtin.template:
      src: ./httpd.conf
      dest: /etc/httpd.conf
    notify:
    - Restart apache

  handlers:
    - name: Restart apache
      ansible.builtin.service:
        name: httpd
        state: restarted
```

In the above example, the Restart apache task will only be triggered when we change something in the configuration. In reality, handlers can be considered inactive tasks waiting to be triggered with a notify statement.

An important thing to note about handlers is that they run by default after all the other tasks have been completed. This way, the handlers only run once, even if triggered many times.

To control this behavior, we can leverage the ***meta: flush_handlers** task that triggers any handlers that have been already notified at that time.

It’s also possible for a task to notify more than one handler in its notify statement.

## Conditional Tasks

To further control execution flow in Ansible, we can leverage conditionals. Conditionals allow us to run or skip tasks based on if certain conditions are met. Variables, facts, or results of previous tasks along with operators, can be used to create such conditions. 

Some examples of use cases could be to update a variable based on a value of another variable, skip a task if a variable has a specific value, execute a task only if a fact from the host returns a value higher than a threshold.

To apply a simple conditional statement, we use the **when** parameter on a task. If the condition is met, the task is executed. Otherwise, it is skipped.

```
- name: Example Simple Conditional
  hosts: all
  vars:
    trigger_task: true

  tasks:
  - name: Install nginx
    apt:
      name: "nginx"
      state: present
    when: trigger_task
```

In the above example, the task is executed since the condition is met. 

Another common pattern is to control task execution based on attributes of the remote host that we can obtain from **facts**. Check out this list with [commonly-used facts](https://docs.ansible.com/ansible/latest/user_guide/playbooks_conditionals.html#commonly-used-facts) to get an idea of all the facts we can utilize in conditions.

```
- name: Example Facts Conditionals 
  hosts: all
  vars:
    supported_os:
      - RedHat
      - Fedora

  tasks:
  - name: Install nginx
    yum:
      name: "nginx"
      state: present
    when: ansible_facts['distribution'] in supported_os
```

It’s possible to combine multiple conditions with logical operators and group them with parenthesis:

```
when: (colour=="green" or colour=="red") and (size="small" or size="medium")
```

Then when statement supports using a list in cases when we have multiple conditions that all need to be true:

```
when:
  - ansible_facts['distribution'] == "Ubuntu"
  - ansible_facts['distribution_version'] == "20.04"
  - ansible_facts['distribution_release'] == "bionic"
```

Another option is to use conditions based on registered variables that we have defined in previous tasks:

```
- name: Example Registered Variables Conditionals
  hosts: all

  tasks:
  - name: Register an example variable
    ansible.builtin.shell: cat /etc/hosts
    register: hosts_contents

  - name: Check if hosts file contains "localhost"
    ansible.builtin.shell: echo "/etc/hosts contains localhost"
    when: hosts_contents.stdout.find(localhost) != -1
```

## Loops

Ansible allows us to iterate over a set of items in a task to execute it multiple times with different parameters without rewriting it. For example, to create several files, we would use a task that iterates over a list of directory names instead of writing five tasks with the same module.

To iterate over a simple list of items, use the **loop** keyword. We can reference the current value with the loop variable **item**.

```
- name: "Create some files"
  ansible.builtin.file:
    state: touch
    path: /tmp/{{ item }}
  loop:
    - example_file1
    - example_file2
    - example_file3
```

The output of the above task that uses _loop_ and _item_:

```
TASK [Create some files] *********************************
changed: [host1] => (item=example_file1)
changed: [host1] => (item=example_file2)
changed: [host1] => (item=example_file3)
```

It’s also possible to iterate over dictionaries:

```
- name: "Create some files with dictionaries"
  ansible.builtin.file:
    state: touch
    path: "/tmp/{{ item.filename }}"
    mode: "{{ item.mode }}"
  loop:
    - { filename: 'example_file1', mode: '755'}
    - { filename: 'example_file2', mode: '775'}
    - { filename: 'example_file3', mode: '777'}
```

Another useful pattern is to iterate over a group of hosts of the inventory:

```
- name: Show all the hosts in the inventory
  ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ groups['databases'] }}"
```

By combining conditionals and loops, we can select to execute the task only on some items in the list and skip it for others:

```
- name: Execute when values in list are lower than 10
  ansible.builtin.command: echo {{ item }}
  loop: [ 100, 200, 3, 600, 7, 11 ]
  when: item < 10
```

Finally, another option is to use the keyword until to retry a task until a condition is true.

```
- name: Retry a task **until** we find the word "success" in the logs
  shell: cat /var/log/example_log
  register: logoutput
  until: logoutput.stdout.find("success") != -1
  retries: 10
  delay: 15
```

In the above example, we check the file _example_log_ 10 times, with a delay of 15 seconds between each check until we find the word success. If we let the task run and add the word success to the example_log file after a while, we notice that the task stops successfully.

```
TASK [Retry a task until we find the word “success” in the logs] *********
FAILED - RETRYING: Retry a task until we find the word "success" in the logs (10 retries left).
FAILED - RETRYING: Retry a task until we find the word "success" in the logs (9 retries left).
changed: [host1]
```

Check out the [official Ansible guide](https://docs.ansible.com/ansible/latest/user_guide/playbooks_loops.html) on Loops for more advanced use cases.

## Ansible Playbooks Tips and Tricks

Keeping these tips and tricks in mind when building your playbooks will help you be more productive and improve your efficiency.

### 1) Keep it as simple as possible
Try to keep your tasks simple. There are many options and nested structures in Ansible, and by combining lots of features, you can end up with fairly complex setups. Spending some time simplifying your Ansible artifacts pays off in the long term.

### 2) Place your Ansible artifacts under version control
It’s considered best practice to store playbooks in git or any other version control system and take advantage of its benefits. 

### 3) Always give descriptive names to your tasks, plays, and playbooks
Choose names that help you and others quickly understand the artifact’s functionality and purpose.

### 4) Strive for readability
Use consistent indentation and add blank lines between tasks to increase readability.

### 5) Always mention the state of tasks explicitly
Many modules have a default state that allows us to skip the state parameter. It’s always better to be explicit in these cases to avoid confusion.

### 6) Use comments when necessary
There will be times when the task definition won’t be enough to explain the whole situation, so feel free to use comments for more complex parts of playbooks.

## Key Points

In this article, we had a look into Ansible’s core automation component, playbooks. We saw how to create, structure, and trigger playbook runs.

Moreover, we explored leveraging variables, handling sensitive data, controlling task execution with handlers and conditions, and iterating over tasks with loops.

Thank you for reading, and I hope you enjoyed this “Ansible: Working with Playbooks” article as much as I did.

Btw. If you are looking to manage infrastructure as code, [Spacelift](https://spacelift.io/product?utm_source=blog&utm_medium=text&utm_id=blogpost&utm_content=%7Bansible_vs_terraform%7D) is the way to go. It supports Git workflows, policy as code, programmatic configuration, context sharing, and many more great features. It currently works with Terraform, Pulumi, and CloudFormation, with support for Ansible on the way! You can test drive it for free, by going [here](https://spacelift.io/free-trial?utm_source=blog&utm_medium=text&utm_id=blogpost&utm_content=%7Bansible_vs_terraform%7D) and creating a trial account.