Tutorial Series
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.
In order to follow along with the practical examples in this series, you’ll need:
ufw
and enable external access to your non-root user profile, both of which will help keep the remote server secure.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.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.
Tutorial
Published on April 15, 2021
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:
---
- 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:
OutputPLAY [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.
Tutorial
Published on April 15, 2021
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:
---
- 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
.
Tutorial
Published on April 15, 2021
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:
---
- 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.
Tutorial
Published on April 15, 2021
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:
Output203.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:
---
- 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.
Tutorial
Published on April 15, 2021
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:
---
- 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:
---
- 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:
...
[secondary_label 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.
Tutorial
Published on April 15, 2021
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:
---
- 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.
Tutorial
Published on April 15, 2021
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:
---
- 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:
---
- 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
OutputBECOME 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.
Tutorial
Published on April 15, 2021
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:
---
- 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
OutputBECOME 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:
---
- 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
:
OutputBECOME 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.
Tutorial
Published on April 15, 2021
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:
<!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:
---
- 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
OutputBECOME 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:
That means your playbook worked as expected, and the default Nginx page was replaced by the template you have created.
Tutorial
Published on April 15, 2021
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:
---
- 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
OutputBECOME 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:
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.
Tutorial
Published on April 15, 2021
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.
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
Outputtotal 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
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:
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.
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:
---
- 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.
The following task will update the apt
cache and then install the nginx
package on remote nodes:
. . .
- name: Update apt cache and install Nginx
apt:
name: nginx
state: latest
update_cache: yes
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.
. . .
- name: Copy website files to the server's document root
copy:
src: "{{ app_root }}"
dest: "{{ document_root }}"
mode: preserve
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:
. . .
- 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
Next, include the task that enables tcp access on port 80:
. . .
- name: Allow all access to tcp port 80
ufw:
rule: allow
port: '80'
proto: tcp
. . .
To finish this playbook, the only thing left to do is to set up the Restart Nginx
handler:
. . .
handlers:
- name: Restart Nginx
service:
name: nginx
state: restarted
Once you’re finished including all the required tasks in your playbook file, it will look like this:
---
- 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:
OutputBECOME 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:
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.
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: