How To Write Ansible Playbooks

46k views

Introduction

Ansible is a modern configuration management tool that doesn’t require the use of an agent software on remote nodes. Instead, it uses only SSH and Python to communicate and execute commands on managed servers.

Ansible allows users to manage servers in two different ways: via ad hoc commands, and via playbooks. Playbooks are YAML files containing a list of ordered tasks that should be executed on a remote server to complete a task or reach a certain goal, such as to set up a LEMP environment. Ansible playbooks allow you to fully automate server setup and application deployment, using an accessible syntax and an extensive library of built-in resources.

This series will walk you through some of Ansible’s main features which you can use to write playbooks for server automation. At the end, you’ll create a playbook to automate setting up a remote Nginx web server and deploy a static HTML website to it. The playbook examples used in this series can be found in our ansible-practice repository at the DigitalOcean Community organization on GitHub.

Prerequisites

In order to follow along with the practical examples in this series, you’ll need:

  • One Ansible Control Node: The Ansible control node is the machine we’ll use to connect to and control the Ansible hosts over SSH. Your Ansible control node can either be your local machine or a server dedicated to running Ansible, though this series assumes your control node is an Ubuntu 20.04 system. Make sure the control node has a non-root user with sudo privileges. To set this up, you can follow Steps 2 and 3 of our Initial Server Setup Guide for Ubuntu 20.04. However, please note that if you’re using a remote server as your Ansible Control node, you should follow every step of this guide. Doing so will configure a firewall on the server with ufw and enable external access to your non-root user profile, both of which will help keep the remote server secure.
  • An SSH keypair associated with your control node’s non-root user. To set this up, you can follow Step 1 of our guide on How to Set Up SSH Keys on Ubuntu 20.04.
  • One or more Ansible Hosts: An Ansible host is any machine that your Ansible control node is configured to automate. This guide assumes your Ansible hosts are remote Ubuntu 20.04 servers. Make sure each Ansible host has the Ansible control node’s SSH public key added to the authorized_keys of a system user. This user can be either root or a regular user with sudo privileges. To set this up, you can follow Step 2 of How to Set Up SSH Keys on Ubuntu 20.04.
  • Ansible installed and configured on your control node. To set up Ansible, please follow our guide on How to Install and Configure Ansible on Ubuntu 20.04.
  • A working Ansible inventory file. This inventory file should be set up on your control node and should contain all of your Ansible hosts. The guide How To Set Up Ansible Inventories explains in detail how you can create an Ansible inventory file.

Once you have met these prerequisites, run a connection test as outlined in our guide on How To Manage Multiple Servers with Ansible Ad Hoc Commands to make sure you’re able to connect and execute Ansible instructions on your remote nodes.

Playbooks use the YAML format to define one or more plays. A play is a set of ordered tasks that are arranged in a way to automate a process, such as setting up a web server or deploying an application to production.

In a playbook file, plays are defined as a YAML list. A typical play starts off by determining which hosts are the target of that particular setup. This is done with the hosts directive.

Setting the hosts directive to all is a common choice because you can limit the targets of a play at execution time by running the ansible-playbook command with the -l parameter. That allows you to run the same playbook on different servers or groups without the need to change the playbook file every time.

Start by creating a new directory on your home folder where you can save your practice playbooks. First, make sure you’re in your Ubuntu user’s home directory. From there, create a directory named ansible-practice and then navigate into that directory with the cd command:

  • cd ~
  • mkdir ansible-practice
  • cd ansible-practice

If you followed all prerequisites, you should already have a working inventory file. You can copy that file into your new ansible-practice directory now. For instance, if you created your test inventory file in an ansible directory in your home folder, you could copy the file to the new directory with:

  • cp ~/ansible/inventory ~/ansible-practice/inventory

Next, create a new playbook file:

  • nano playbook-01.yml

The following playbook defines a play targeting all hosts from a given inventory. It contains a single task to print a debug message.

Note: We’ll learn more about tasks in the next section of this series.

Add the following content to your playbook-01.yml file:

~/ansible-practice/playbook-01.yml
---
- hosts: all
  tasks:
    - name: Print message
      debug:
        msg: Hello Ansible World

Save and close the file when you’re done. If you’re using nano, you can do that by typing CTRL+X, then Y and ENTER to confirm.

To try this playbook on the server(s) that you set up in your inventory file, run ansible-playbook with the same connection arguments you used when running a connection test within the introduction of this series. Here, we’ll be using an inventory file named inventory and the sammy user to connect to the remote server, but be sure to change these details to align with your own inventory file and administrative user:

  • ansible-playbook -i inventory playbook-01.yml -u sammy

You’ll see output like this:

Output
PLAY [all] *********************************************************************************** TASK [Gathering Facts] *********************************************************************** ok: [203.0.113.10] TASK [Update apt cache] ********************************************************************** ok: [203.0.113.10] => { "msg": "Hello Ansible World" } PLAY RECAP *********************************************************************************** 203.0.113.10 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

You might have noticed that even though you have defined only one task within your playbook, two tasks were listed in the play output. At the beginning of each play, Ansible executes by default an additional task that gathers information — referred to as facts — about the remote nodes. Because facts can be used on playbooks to better customize the behavior of tasks, the fact-gathering task must happen before any other tasks are executed.

We’ll learn more about Ansible facts in a later section of this series.

A task is the smallest unit of action you can automate using an Ansible playbook. Playbooks typically contain a series of tasks that serve a goal, such as to set up a web server, or to deploy an application to remote environments.

Ansible executes tasks in the same order they are defined inside a playbook. Before automating a procedure such as setting up a LEMP server, you’ll need to assess which manual steps are necessary and the order in which they must be completed to get everything done. Then, you’ll be able to determine which tasks you’ll need and which modules you can use to reach your goals in less steps.

Modules offer shortcuts to execute operations that you would otherwise have to run as raw bash commands. These are also often used to abstract commands across different operating systems.

When you created your first playbook in a previous part of this guide, you defined a single task that outputs a message using debug. Let’s have a look at that playbook once again. You can use the cat command to print the contents of that file for examination:

  • cat ~/ansible-practice/playbook-01.yml

This playbook contains a single task that prints a message in the output of a play:

~/ansible-practice/playbook-01.yml
---
- hosts: all
  tasks:
    - name: Print message
      debug:
        msg: Hello Ansible World

Tasks are defined as a list under the name tasks inside a play, at the same level as the hosts directive that defines the targets for that play. The name property defines the output that will be printed out when that task is about to be executed.

The example task invokes the debug module, which allows you to display messages in a play. These messages can be used to show debug information such as the contents of a variable or the output message returned by a command, for instance.

Each module has its own set of options and properties. The debug module expects a property named msg containing the message to be printed out. Pay special attention to the indentation (2 spaces), since msg must be a property inside debug.

Ansible supports the use of variables to better customize the execution of tasks and playbooks. This way, it’s possible to use the same playbook with different targets and environments.

Variables can come from different sources, such as the playbook file itself or external variable files that are imported in the playbook. Special precedence rules will apply when working with multiple variable sources that define a variable with the same name.

To see how variables work in practice, we’ll create a new test playbook that will print the value of two variables, username and home_dir. Create a new file called playbook-02.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-02.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-02.yml
---
- hosts: all
  vars:
    - username: sammy
    - home: /home/sammy   
  tasks:
    - name: print variables
      debug:
        msg: "Username: {{ username }}, Home dir: {{ home }}"

Save and close the file when you’re done editing.

The vars section of the playbook defines a list of variables that will be injected in the scope of that play. All tasks, as well as any file or template that might be included in the playbook, will have access to these variables.

To try this playbook on servers from your inventory file, run ansible-playbook with the same connection arguments you’ve used before when running our first example. Again, we’ll be using an inventory file named inventory and the sammy user to connect to the remote servers:

  • ansible-playbook -i inventory playbook-02.yml -u sammy

You’ll see output like this:

Output
PLAY [all] *********************************************************************************************************************************************************************************** TASK [Gathering Facts] *********************************************************************************************************************************************************************** ok: [203.0.113.10] TASK [print variables] *********************************************************************************************************************************************************************** ok: [203.0.113.10] => { "msg": "Username: sammy, Home dir: /home/sammy" } PLAY RECAP *********************************************************************************************************************************************************************************** 203.0.113.10 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

The print variables task will use the debug module to print the values of the two variables we defined in the vars section of the playbook.

By default, before executing the set of tasks defined in a playbook, Ansible will take a few moments to gather information about the systems that are being provisioned. This information, referred to as facts, contain details such as network interfaces and addresses, the operating system running on remote nodes, and available memory, among other things.

Ansible stores facts in JSON format, with items grouped in nodes. To check what kind of information is available for the systems you’re provisioning, you can run the setup module with an ad hoc command:

  • ansible all -i inventory -m setup -u sammy

This command will output an extensive JSON containing information about your server. To obtain a subset of that data, you can use the filter parameter and provide a pattern. For instance, if you’d like to obtain information about all IPv4 addresses in the remote nodes, you can use the following command:

  • ansible all -i inventory -m setup -a "filter=*ipv4*" -u sammy

You’ll see output like this:

Output
203.0.113.10 | SUCCESS => { "ansible_facts": { "ansible_all_ipv4_addresses": [ "203.0.113.10", "198.51.100.23" ], "ansible_default_ipv4": { "address": "203.0.113.10", "alias": "eth0", "broadcast": "203.0.113.255", "gateway": "203.0.113.1", "interface": "eth0", "macaddress": "06:c7:91:16:2e:b7", "mtu": 1500, "netmask": "203.0.113.0", "network": "203.0.113.0", "type": "ether" } }, "changed": false }

Once you have found the facts that will be useful for your play, you can update your playbook accordingly. As an example, the following playbook will print out the IPv4 address of the default network interface. From the previous command output, we can see that this value is available through ansible_default_ipv4.address in the JSON provided by Ansible.

Create a new file called playbook-03.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-03.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-03.yml
---
- hosts: all
  tasks:
    - name: print facts
      debug:
        msg: "IPv4 address: {{ ansible_default_ipv4.address }}"

Save and close the file when you’re done.

To try this playbook on servers from your inventory file, run ansible-playbook with the same connection arguments you’ve used before when running our first example. Again, we’ll be using an inventory file named inventory and the sammy user to connect to the remote servers:

  • ansible-playbook -i inventory playbook-03.yml -u sammy

When you run the playbook, you’ll see your remote server’s IPv4 address in the output as expected:

Output
... TASK [print facts] *************************************************************************************************************************************************************************** ok: [server1] => { "msg": "IPv4 address: 203.0.113.10" } ...

Facts encapsulate important data that you can leverage to better customize your playbooks. To learn more about all the information you can obtain through facts, please refer to the official Ansible documentation.

In Ansible, you can define conditions that will be evaluated before a task is executed. When a condition is not met, the task is then skipped. This is done with the when keyword, which accepts expressions that are typically based on a variable or a fact.

The following example defines two variables: create_user_file and user. When the create_user_file is evaluated to true, a new file will be created in the home directory of the user defined by the user variable:

Create a new file called playbook-04.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-04.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-04.yml
---
- hosts: all
  vars:
    - create_user_file: yes
    - user: sammy  
  tasks:
    - name: create file for user
      file:
        path: /home/{{ user }}/myfile
        state: touch
      when: create_user_file

Save and close the file when you’re done editing its contents.

To execute this playbook on servers from your inventory file, run ansible-playbook with the same connection arguments you’ve used before when running other playbooks in this series. Again, we’ll be using an inventory file named inventory and the sammy user to connect to the remote servers:

  • ansible-playbook -i inventory playbook-04.yml -u sammy

When the condition is met, you’ll see a changed status in the play output:

Output
... TASK [create file for user] ***************************************************************************** changed: [203.0.113.10] ...

If you change the value of create_user_file to no, the condition will be evaluated to false. In this case, you’ll see a skipping status in the play output, indicating that the task was not executed:

Output
... TASK [create file for user] ***************************************************************************** skipping: [203.0.113.10] ...

A common use for conditionals in the context of Ansible playbooks is to combine them with register, a keyword that creates a new variable and assigns it with the output obtained from a command. This way, you can use any external command to evaluate the execution of a task.

One important thing to notice is that, by default, Ansible will interrupt a play if the command you’re using to evaluate a condition fails. For that reason, you’ll need to include an ignore_errors directive set to yes in said task, and this will make Ansible move on to the next task and continue the play.

The following example will only create a new file in the user home directory in case that file doesn’t exist yet, which we’ll test with an ls command. If the file exists, however, we’ll show a message using the debug module.

Create a new file called playbook-05.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-05.yml

Then add the following content to the new playbook file:

~/ansible-practice/playbook-05.yml
---
- hosts: all
  vars:
    - user: sammy
  tasks:
    - name: Check if file already exists
      command: ls /home/{{ user }}/myfile
      register: file_exists
      ignore_errors: yes

    - name: create file for user
      file:
        path: /home/{{ user }}/myfile
        state: touch
      when: file_exists is failed

    - name: show message if file exists
      debug:
        msg: The user file already exists.
      when: file_exists is succeeded

Save and close the file when you’re done.

Then, run ansible-playbook with the same connection arguments from the previous examples. Here, we’re using an inventory file named inventory and a user named sammy, but you should change these values accordingly:

  • ansible-playbook -i inventory playbook-05.yml -u sammy

The first time you run this playbook, the command will fail because the file doesn’t exist in that path. The task that creates the file will then be executed, while the last task will be skipped:

Output
... TASK [Check if file already exists] ********************************************************************* fatal: [203.0.113.10]: FAILED! => {"changed": true, "cmd": ["ls", "/home/sammy/myfile"], "delta": "0:00:00.004258", "end": "2020-10-22 13:10:12.680074", "msg": "non-zero return code", "rc": 2, "start": "2020-10-22 13:10:12.675816", "stderr": "ls: cannot access '/home/sammy/myfile': No such file or directory", "stderr_lines": ["ls: cannot access '/home/sammy/myfile': No such file or directory"], "stdout": "", "stdout_lines": []} ...ignoring TASK [create file for user] ***************************************************************************** changed: [203.0.113.10] TASK [show message if file exists] ********************************************************************** skipping: [203.0.113.10] ...

From the output, you can see that the create file for user task caused a change in the server, which means the file was created. Now, run the playbook again and you’ll get a different result:

  • ansible-playbook -i inventory playbook-05.yml -u sammy
Output
... TASK [Check if file already exists] ********************************************************************* changed: [203.0.113.10] TASK [create file for user] ***************************************************************************** skipping: [203.0.113.10] TASK [show message if file exists] ********************************************************************** ok: [203.0.113.10] => { "msg": "The user file already exists." } ...

If you’d like to learn more about using conditionals in Ansible playbooks, please refer to the official documentation.

When automating server setup, sometimes you’ll need to repeat the execution of the same task using different values. For instance, you may need to change permissions of multiple files, or create multiple users. To avoid repeating the task several times in your playbook file, it’s better to use loops instead.

In programming, a loop allows you to repeat instructions, typically until a certain condition is met. Ansible offers different looping methods, with the loop keyword being the most recommended option for longer term compatibility.

The following example creates three different files on the /tmp location. It uses the file module within a task that implements a loop using three different values.

Create a new file called playbook-06.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-06.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-06.yml
---
- hosts: all
  tasks:
    - name: creates users files
      file:
        path: /tmp/ansible-{{ item }}
        state: touch
      loop:
        - sammy
        - erika
        - brian

Save and close the file when you’re done.

Then, run ansible-playbook with the same connection arguments from the previous examples. Again, we’re using an inventory file named inventory and a user named sammy, but you should change these values accordingly:

  • ansible-playbook -i inventory playbook-06.yml -u sammy

You’ll get output like this, showing each individual item value that was used within the loop:

Output
... TASK [creates users files] ****************************************************************************** changed: [203.0.113.10] => (item=sammy) changed: [203.0.113.10] => (item=erika) changed: [203.0.113.10] => (item=brian) ...

For more detailed information on how to use loops when writing Ansible playbooks, please refer to the official documentation.

Just as with regular commands that you execute on a terminal, some tasks will require special privileges in order for Ansible to execute them successfully on your remote nodes.

It is important to understand how privilege escalation works in Ansible so that you’re able to execute your tasks with appropriate permissions. By default, tasks will run as the connecting user - this might be either root or any regular user with SSH access to the remote nodes in an inventory file.

To run a command with extended permissions, such as a command that requires sudo, you’ll need to include a become directive set to yes in your play. This can be done either as a global setting valid to all tasks in that play, or as an individual instruction applied per task. Depending on how your sudo user is set up within the remote nodes, you may also need to provide the user’s sudo password. The following example updates the apt cache, a task that requires root permissions.

Create a new file called playbook-07.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-07.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-07.yml
---
- hosts: all
  become: yes
  tasks:
    - name: Update apt cache
      apt:
        update_cache: yes

Save and close the file when you’re done.

To run this playbook, you’ll need to include the -K option within the ansible-playbook command. This will make Ansible prompt you for the sudo password for the specified user.

  • ansible-playbook -i inventory playbook-07.yml -u sammy -K

You can also change which user you want to switch to while executing a task or play. To do that, set the become_user directive to the name of the remote user you want to switch to. This is useful when you have several tasks in a playbook that rely on sudo, but also a few tasks that should run as your regular user.

The following example defines that all tasks in this play will be executed with sudo by default. This is set at the play level, right after the hosts definition. The first task creates a file on /tmp using root privileges, since that is the default became_user value. The last task, however, defines its own become_user.

Create a new file called playbook-08.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-08.yml

Add the following content to the new playbook file:

~/ansible-practice/playbook-08.yml
---
- hosts: all
  become: yes
  vars:
    user: "{{ ansible_env.USER }}"
  tasks:
    - name: Create root file
      file:
        path: /tmp/my_file_root
        state: touch

    - name: Create user file
      become_user: "{{ user }}"
      file:
        path: /tmp/my_file_{{ user }}
        state: touch

Save and close the file when you’re finished.

The ansible_env.USER fact contains the username of the connecting user, which can be defined at execution time when running the ansible-playbook command with the -u option. Throughout this guide, we’re connecting as sammy:

  • ansible-playbook -i inventory playbook-08.yml -u sammy -K
Output
BECOME password: PLAY [all] ********************************************************************************************** TASK [Gathering Facts] ********************************************************************************** ok: [203.0.113.10] TASK [Create root file] ********************************************************************************* changed: [203.0.113.10] TASK [Create user file] ********************************************************************************* changed: [203.0.113.10] PLAY RECAP ********************************************************************************************** 203.0.113.10 : ok=3 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

When the playbook is finished running, you can log onto the remote node(s) to verify that two new files were created on /tmp, each with different ownership information:

  • ssh sammy@203.0.113.10
  • ls -la /tmp/my_file*
Output
-rw-r--r-- 1 root root 0 Apr 14 13:19 /tmp/my_file_root -rw-r--r-- 1 sammy sudo 0 Apr 14 12:07 /tmp/my_file_sammy

For more detailed information about privilege escalation in Ansible, please refer to the official documentation.

Automating the installation of required system packages is a common operational task in Ansible playbooks, since a typical application stack requires software from different sources.

The apt module manages system packages on Debian-based operating systems such as Ubuntu, the distribution we’re using on remote nodes throughout this guide. The following playbook will update the apt cache and then make sure Vim is installed on remote nodes.

Create a new file called playbook-09.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-09.yml

Then add the following lines to the new playbook file:

~/ansible-practice/playbook-09.yml
---
- hosts: all
  become: yes
  tasks:
    - name: Update apt cache and make sure Vim is installed
      apt:
        name: vim
        update_cache: yes

Save and close the file when you’re done.

Notice that we’ve included the become directive in the beginning of the play. This is required since installing packages requires administrative system permissions.

Removing a package is done in a similar way, the only change is that you have to define the package state to absent. The state directive has a default value of present, which will make sure that the package is installed on the system, regardless of the version. The package will be installed if not present. To assure you have the latest version of a package, you can use latest instead. This will cause apt to update the requested package if that is not on their latest version.

Remember to provide the -K option when running this playbook, since it requires sudo permissions:

  • ansible-playbook -i inventory playbook-09.yml -u sammy -K
Output
BECOME password: PLAY [all] ********************************************************************************************** TASK [Gathering Facts] ********************************************************************************** ok: [203.0.113.10] TASK [Update apt cache and make sure Vim is installed] ************************************************** ok: [203.0.113.10] PLAY RECAP ********************************************************************************************** 203.0.113.10 : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

When installing multiple packages, you can use a loop and provide an array containing the names of the packages you want to install. The following playbook will make sure the packages vim, unzip, and curl are installed and in their latest version.

Create a new file called playbook-10.yml in your ansible-practice directory, on your Ansible control node:

  • nano ~/ansible-practice/playbook-10.yml

Add the following content to the new playbook file:

~/ansible-practice/playbook-10.yml
---
- hosts: all
  become: yes
  tasks:
    - name: Update apt cache and make sure Vim, Curl and Unzip are installed
      apt:
        name: "{{ item }}"
        update_cache: yes
      loop:
        - vim
        - curl
        - unzip

Save and close the file when you have finished.

Then, run ansible-playbook with the same connection arguments from the previous examples, and don’t forget to include the -K option since this playbook requires administrative privileges:

  • ansible-playbook -i inventory playbook-09.yml -u sammy -K

You’ll see output like this, indicating that the same task run through three iterations using the different values we have provided: vim, curl, and unzip:

Output
BECOME password: PLAY [all] *************************************************************************************************************************************** TASK [Gathering Facts] *************************************************************************************************************************** ok: [203.0.113.10] TASK [Update apt cache and make sure Vim, Curl and Unzip are installed] ************************************************************************** ok: [203.0.113.10] => (item=vim) ok: [203.0.113.10] => (item=curl) changed: [203.0.113.10] => (item=unzip) PLAY RECAP *************************************************************************************************************************************** 203.0.113.10 : ok=2 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

For more details on how to manage system packages, including how to remove packages and how to use advanced apt options, you can refer to the official documentation.

Templates allow you to create new files on the nodes using predefined models based on the Jinja2 templating system. Ansible templates are typically saved as .tpl files and support the use of variables, loops, and conditional expressions.

Templates are commonly used to configure services based on variable values that can be set up on the playbook itself, in included variable files, or obtained via facts. This enables you to create more versatile setups that adapt behavior based on dynamic information.

To try it out this feature with a practical example, create a new directory to hold non-playbook files inside your ansible-practice directory:

  • mkdir ~/ansible-practice/files

Next, create a new template file for an HTML landing page. Later on, we’ll set up a playbook which will configure your remote nodes to serve the landing page with Nginx:

  • nano ~/ansible-practice/files/landing-page.html.j2

Add the following content to the template file:

~/ansible-practice/files/landing-page.html.j2
<!doctype html>
<html lang="en">
<head>
  <meta charset="utf-8">
  <title>{{ page_title }}</title>
  <meta name="description" content="Created with Ansible">
</head>
<body>
    <h1>{{ page_title }}</h1>
    <p>{{ page_description }}</p>
</body>
</html>

Save and close the file when you’re done.

This template uses two variables that must be provided whenever the template is applied in a playbook: page_title and page_description.

The following playbook sets up the required variables, installs Nginx, and then applies the specified template to replace the existing, default Nginx landing page located at /var/www/html/index.nginx-debian.html. The last task uses the ufw module to enable tcp access on port 80, in case you have your firewall enabled as recommended in our initial server setup guide.

Create a new file called playbook-11.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-11.yml

Add the following content to the new playbook file:

~/ansible-practice/playbook-11.yml
---
- hosts: all
  become: yes
  vars:
    page_title: My Landing Page
    page_description: This is my landing page description.
  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: latest

    - name: Apply Page Template
      template:
        src: files/landing-page.html.j2
        dest: /var/www/html/index.nginx-debian.html

    - name: Allow all access to tcp port 80
      ufw:
        rule: allow
        port: '80'
        proto: tcp    

Remember to provide the -K option if you run this playbook, since it requires sudo permissions:

  • ansible-playbook -i inventory playbook-11.yml -u sammy -K
Output
BECOME password: PLAY [all] ********************************************************************************************** TASK [Gathering Facts] ********************************************************************************** ok: [203.0.113.10] TASK [Install Nginx] ************************************************************************************ changed: [203.0.113.10] TASK [Apply Page Template] ****************************************************************************** changed: [203.0.113.10] TASK [Allow all access to tcp port 80] ****************************************************************** changed: [203.0.113.10] PLAY RECAP ********************************************************************************************** 203.0.113.10 : ok=4 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

When the play has finished, you can access the web server’s public IP address from your browser. You’ll see a page like this:

Screenshot showing custom landing page

That means your playbook worked as expected, and the default Nginx page was replaced by the template you have created.

In a nutshell, handlers are special tasks that only get executed when triggered via the notify directive. Handlers are executed at the end of the play, once all tasks are finished.

In Ansible, handlers are typically used to start, reload, restart, and stop services. If your playbook involves changing configuration files, there is a high chance that you’ll need to restart a service so that the changes take effect. In this case, you’ll need to define a handler for that service and include the notify directive in any tasks that require that service handler.

In a previous section of this series, you’ve seen how to use a template to replace the default Nginx page with a custom HTML landing page. In practice, when setting up your Nginx web server, you’re most likely going to include new server block files in your sites-available directory, create symbolic links, or change settings that require a server reload or restart.

Considering such a scenario, this is how a handler to restart the Nginx service would look like:

...
  handlers:
    - name: Restart Nginx
      service:
        name: nginx
        state: restarted     

To trigger this handler, you’ll need to include a notify directive in any task that requires a restart on the Nginx server.

The following playbook replaces the default document root in Nginx’s configuration file using the built-in Ansible module replace. This module looks for patterns in a file based on a regular expression defined by regexp, and then replaces any matches found with the content defined by replace. The task then sends a notification to the Restart Nginx handler for a restart as soon as possible. What that means is, it doesn’t matter how many times you trigger the restart, it will only happen when all tasks are already finished executing and the handlers start running. Additionally, when no matches are found, no changes are made to the system, and for that reason the handler is not triggered.

Create a new file called playbook-12.yml in your ansible-practice directory:

  • nano ~/ansible-practice/playbook-12.yml

Add the following lines to the new playbook file:

ansible-practice/playbook-12.yml
---
- hosts: all
  become: yes
  vars:
    page_title: My Second Landing Page
    page_description: This is my second landing page description.
    doc_root: /var/www/mypage

  tasks:
    - name: Install Nginx
      apt:
        name: nginx
        state: latest

    - name: Make sure new doc root exists
      file:
        path: "{{ doc_root }}"
        state: directory
        mode: '0755'

    - name: Apply Page Template
      template:
        src: files/landing-page.html.j2
        dest: "{{ doc_root }}/index.html"

    - name: Replace document root on default Nginx configuration
      replace:
        path: /etc/nginx/sites-available/default
        regexp: '(\s+)root /var/www/html;(\s+.*)?$'
        replace: \g<1>root {{ doc_root }};\g<2>
      notify: Restart Nginx

    - name: Allow all access to tcp port 80
      ufw:
        rule: allow
        port: '80'
        proto: tcp

  handlers:
    - name: Restart Nginx
      service:
        name: nginx
        state: restarted

Save and close the file when you’re done.

One important thing to keep in mind when using handlers is that they are only triggered when the task that defines the notify trigger causes a change in the server. Taking this playbook into account, the first time it runs the replace task it will change the Nginx configuration file and thus the restart will run. In subsequent executions, however, since the string to be replaced is not present in the file anymore, the task won’t cause any changes and won’t trigger the handler execution.

Remember to provide the -K option if you run this playbook, since it requires sudo permissions:

  • ansible-playbook -i inventory playbook-12.yml -u sammy -K
Output
BECOME password: PLAY [all] ********************************************************************************************** TASK [Gathering Facts] ********************************************************************************** ok: [203.0.113.10] TASK [Install Nginx] ************************************************************************************ ok: [203.0.113.10] TASK [Make sure new doc root exists] ******************************************************************** changed: [203.0.113.10] TASK [Apply Page Template] ****************************************************************************** changed: [203.0.113.10] TASK [Replace document root on default Nginx configuration] ********************************************* changed: [203.0.113.10] TASK [Allow all access to tcp port 80] ****************************************************************** ok: [203.0.113.10] RUNNING HANDLER [Restart Nginx] ************************************************************************* changed: [203.0.113.10] PLAY RECAP ********************************************************************************************** 203.0.113.10 : ok=7 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

If you look at the output, you’ll see the “Restart Nginx” handler being executed just before the end of the play. If you go to your browser and access the server’s IP address now, you’ll see the following page:

Screenshot showing the new landing page after update

In the next and final part of this series, we’ll connect all the dots and put together a playbook that automates setting up a remote Nginx server to host a static HTML website.

If you were following along with all parts of this series, at this point you should be familiar with installing system packages, applying templates, and using handlers in Ansible playbooks. In this part of the series, you’ll use what you’ve seen so far to create a playbook that automates setting up a remote Nginx server to host a static HTML website on Ubuntu 20.04.

Start by creating a new directory on your Ansible control node where you’ll set up the Ansible files and a demo static HTML website to be deployed to your remote server. This could be in any location of your choice within your home folder. In this example we’ll use ~/ansible-nginx-demo.

  • mkdir ~/ansible-nginx-demo
  • cd ~/ansible-nginx-demo

Next, copy your existing inventory file into the new directory. In this example, we’ll use the same inventory you set up at the beginning of this series:

  • cp ~/ansible-practice/inventory .

This will copy a file named inventory from a folder named ansible-practice in your home directory, and save it to the current directory.

Obtaining the Demo Website

For this demonstration, we’ll use a static HTML website that is the subject of our How To Code in HTML series. Start by downloading the demo website files by running the following command:

  • curl -L https://github.com/do-community/html_demo_site/archive/refs/heads/main.zip -o html_demo.zip

You’ll need unzip to unpack the contents of this download. To make sure you have this tool installed, run:

  • sudo apt install unzip

Then, unpack the demo website files with:

  • unzip html_demo.zip

This will create a new directory called html_demo_site-main on your current working directory. You can check the contents of the directory with an ls -la command:

  • ls -la html_demo_site-main
Output
total 28 drwxrwxr-x 3 sammy sammy 4096 sep 18 2020 . drwxrwxr-x 5 sammy sammy 4096 mrt 25 15:03 .. -rw-rw-r-- 1 sammy sammy 1289 sep 18 2020 about.html drwxrwxr-x 2 sammy sammy 4096 sep 18 2020 images -rw-rw-r-- 1 sammy sammy 2455 sep 18 2020 index.html -rw-rw-r-- 1 sammy sammy 1079 sep 18 2020 LICENSE -rw-rw-r-- 1 sammy sammy 675 sep 18 2020 README.md

Creating a Template for Nginx’s Configuration

You’ll now set up the Nginx template that is necessary to configure the remote web server. Create a new folder within your ansible-demo directory to hold non-playbook files:

  • mkdir files

Then, open a new file called nginx.conf.j2:

  • nano files/nginx.conf.j2

This template file contains an Nginx server block configuration for a static HTML website. It uses three variables: document_root, app_root, and server_name. We’ll define these variables later on when creating the playbook. Copy the following content to your template file:

~/ansible-nginx-demo/files/nginx.conf.j2
server {
  listen 80;

  root {{ document_root }}/{{ app_root }};
  index index.html index.htm;

  server_name {{ server_name }};

  location / {
   default_type "text/html";
   try_files $uri.html $uri $uri/ =404;
  }
}

Save and close the file when you’re done.

Creating a New Ansible Playbook

Next, we’ll create a new Ansible playbook and set up the variables that we’ve used in the previous section of this guide. Open a new file named playbook.yml:

  • nano playbook.yml

This playbook starts with the hosts definition set to all and a become directive that tells Ansible to run all tasks as the root user by default (the same as manually running commands with sudo). Within this playbook’s var section, we’ll create three variables: server_name, document_root, and app_root. These variables are used in the Nginx configuration template to set up the domain name or IP address that this web server will respond to, and the full path to where the website files are located on the server. For this demo, we’ll use the ansible_default_ipv4.address fact variable because it contains the remote server’s public IP address, but you can replace this value with your server’s hostname in case it has a domain name properly configured within a DNS service to point to this server:

~/ansible-nginx-demo/playbook.yml
---
- hosts: all
  become: yes
  vars:
    server_name: "{{ ansible_default_ipv4.address }}"
    document_root: /var/www/html
    app_root: html_demo_site-main
  tasks:

You can keep this file open for now. The next sections will walk you through all tasks that you’ll need to include in this playbook to make it fully functional.

Installing Required Packages

The following task will update the apt cache and then install the nginx package on remote nodes:

~/ansible-nginx-demo/playbook.yml
. . .
    - name: Update apt cache and install Nginx
      apt:
        name: nginx
        state: latest
        update_cache: yes

Uploading Website Files to Remote Nodes

The next task will use the copy built-in module to upload the website files to the remote document root. We’ll use the document_root variable to set the destination on the server where the application folder should be created.

~/ansible-nginx-demo/playbook.yml
. . .
    - name: Copy website files to the server's document root
      copy:
        src: "{{ app_root }}"
        dest: "{{ document_root }}"
        mode: preserve

Applying and Enabling the Custom Nginx Configuration

We’ll now apply the Nginx template that will configure the web server to host your static HTML file. After the configuration file is set at /etc/nginx/sites-available, we’ll create a symbolic link to that file inside /etc/nginx-sites-enabled and notify the Nginx service for a posterior restart. The entire process will require two separate tasks:

~/ansible-nginx-demo/playbook.yml
. . .
    - name: Apply Nginx template
      template:
        src: files/nginx.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: Restart Nginx

    - name: Enable new site
      file:
        src: /etc/nginx/sites-available/default
        dest: /etc/nginx/sites-enabled/default
        state: link
      notify: Restart Nginx

Allowing Port 80 on UFW

Next, include the task that enables tcp access on port 80:

~/ansible-nginx-demo/playbook.yml
. . .
    - name: Allow all access to tcp port 80
      ufw:
        rule: allow
        port: '80'
        proto: tcp
. . .

Creating a Handler for the Nginx Service

To finish this playbook, the only thing left to do is to set up the Restart Nginx handler:

~/ansible-nginx-demo/playbook.yml
. . .
  handlers:
    - name: Restart Nginx
      service:
        name: nginx
        state: restarted  

Running the Finished Playbook

Once you’re finished including all the required tasks in your playbook file, it will look like this:

~/ansible-nginx-demo/playbook.yml
---
- hosts: all
  become: yes
  vars:
    server_name: "{{ ansible_default_ipv4.address }}"
    document_root: /var/www
    app_root: html_demo_site-main
  tasks:
    - name: Update apt cache and install Nginx
      apt:
        name: nginx
        state: latest
        update_cache: yes

    - name: Copy website files to the server's document root
      copy:
        src: "{{ app_root }}"
        dest: "{{ document_root }}"
        mode: preserve

    - name: Apply Nginx template
      template:
        src: files/nginx.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: Restart Nginx

    - name: Enable new site
      file:
        src: /etc/nginx/sites-available/default
        dest: /etc/nginx/sites-enabled/default
        state: link
      notify: Restart Nginx

    - name: Allow all access to tcp port 80
      ufw:
        rule: allow
        port: '80'
        proto: tcp

  handlers:
    - name: Restart Nginx
      service:
        name: nginx
        state: restarted

To execute this playbook on the server(s) that you set up in your inventory file, run ansible-playbook with the same connection arguments you’ve used when running a connection test within the introduction of this series. Here, we’ll be using an inventory file named inventory and the sammy user to connect to the remote server. Because the playbook requires sudo to run, we’re also including the -K argument to provide the remote user’s sudo password when prompted by Ansible:

  • ansible-playbook -i inventory playbook.yml -u sammy -K

You’ll see output like this:

Output
BECOME password: PLAY [all] ********************************************************************************************** TASK [Gathering Facts] ********************************************************************************** ok: [203.0.113.10] TASK [Update apt cache and install Nginx] *************************************************************** ok: [203.0.113.10] TASK [Copy website files to the server's document root] ************************************************* changed: [203.0.113.10] TASK [Apply Nginx template] ***************************************************************************** changed: [203.0.113.10] TASK [Enable new site] ********************************************************************************** ok: [203.0.113.10] TASK [Allow all access to tcp port 80] ****************************************************************** ok: [203.0.113.10] RUNNING HANDLER [Restart Nginx] ************************************************************************* changed: [203.0.113.10] PLAY RECAP ********************************************************************************************** 203.0.113.10 : ok=7 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0

Once the playbook is finished, if you go to your browser and access your server’s hostname or IP address you should now see the following page:

HTML Demo Site Deployed by Ansible

Congratulations, you have successfully automated the deployment of a static HTML website to a remote Nginx server, using Ansible.

If you make changes to any of the files in the demo website, you can run the playbook again and the copy task will make sure any file changes are reflected in the remote host. Because Ansible has an idempotent behavior, running the playbook multiple times will not trigger changes that were already made to the system.

Conclusion

By following this series, you learned how to use in practice some of the most common features of Ansible playbooks, such as variables, conditionals, loops, templates, and handlers. You created a playbook to automate the deployment of a remote Nginx web server, with a custom server template and a task to copy your local website files to the remote server’s document root.

To learn more about Ansible and its command line interface, you can check our series on How To Manage Remote Servers with Ansible, and our Ansible Reference Guide. Visit our Ansible tag page for more content, including workshops and video presentations, about this tool.

For more information about the subjects discussed in this series, check the following resources: