Skip to content

Configure a server with Ansible

In this tutorial, we will use Ansible to configure a small web server on a Debian 13 machine. Instead of typing the commands that install and configure nginx, you will write down the state the server should be in, and let Ansible make it so. By the end, you will have a playbook that installs nginx, writes a site configuration and a home page from templates, and restarts nginx only when its configuration changes. You will also have run it again and again, and seen that a second run changes nothing and that Ansible puts back a file someone edited by hand.

To keep things simple, Ansible will manage the same machine it runs on. Exactly the same playbook can manage other servers over SSH later.

Before you begin

  • A fresh Debian 13 ("trixie") server or virtual machine, with nothing listening on TCP port 8080.
  • An ordinary user account on it that can use sudo without typing a password, as the default user of Debian's cloud images can.
  • You are logged in to that account. The earlier lesson First steps on a Linux server covers everything else you need.

In the examples, the user is called labuser and the server server1.

Step 1: Install Ansible

Ansible is packaged in Debian. Install it:

sudo apt-get update
sudo apt-get install -y ansible

The ansible package brings in ansible-core, the engine and its built-in modules, and a large set of community collections. Check which version you have:

$ ansible --version
ansible [core 2.19.11]
  config file = None
  configured module search path = ['/home/labuser/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/labuser/.ansible/collections:/usr/share/ansible/collections
  executable location = /usr/bin/ansible
  python version = 3.13.5 (main, Aug 10 2026, 12:06:59) [GCC 14.2.0] (/usr/bin/python3)
  jinja version = 3.1.6
  pyyaml version = 6.0.2 (with libyaml v0.2.5)

Notice the python version line. Ansible is written in Python, and the small programs it runs on each managed machine (its modules) are Python too.

Step 2: Write an inventory

Ansible needs to know which machines it manages. That list is called the inventory. Create a directory for the project and an inventory file in it:

mkdir -p ~/webserver/templates
cd ~/webserver

Create ~/webserver/inventory.ini with this content:

[webservers]
localhost ansible_connection=local ansible_python_interpreter=/usr/bin/python3

This defines a group called webservers with one host in it, localhost. ansible_connection=local tells Ansible to run commands directly on this machine instead of connecting to it over SSH. ansible_python_interpreter tells it which Python to use there, so it doesn't have to search for one.

Ask Ansible what it sees in the inventory:

$ cd ~/webserver
$ ansible-inventory -i inventory.ini --graph
@all:
  |--@ungrouped:
  |--@webservers:
  |  |--localhost

Every host is also in the built-in group all. Now check that Ansible can reach the host and run a module on it, with the ping module:

$ cd ~/webserver
$ ansible -i inventory.ini webservers -m ansible.builtin.ping
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

SUCCESS and "ping": "pong" mean Ansible ran its Python module on the host and got an answer. Notice "changed": false: every Ansible module reports whether it changed anything. That line will matter a lot in the next steps.

Step 3: Write the templates

The web server needs two files: an nginx site configuration and a home page. Instead of copying fixed files, you'll write templates: files with placeholders in double braces that Ansible fills in from variables when it writes them. Ansible's templates use the Jinja2 language.

Create ~/webserver/templates/site.conf.j2, the nginx configuration:

# Managed by Ansible: changes made here will be overwritten.
server {
    listen {{ http_port }};
    server_name _;
    root /srv/www/site;
}

Create ~/webserver/templates/index.html.j2, the home page:

<h1>{{ site_title }}</h1>
<p>Served by {{ ansible_facts['hostname'] }}.</p>

Check that both templates are in place:

$ ls ~/webserver/templates
index.html.j2  site.conf.j2

http_port and site_title are variables you'll set in the playbook. ansible_facts['hostname'] is a fact: information Ansible collects about each host before it does anything else.

Step 4: Write the playbook

A playbook lists the state you want, as a sequence of tasks. Each task calls one module with arguments that describe the result, not the commands to get there. Create ~/webserver/site.yml:

- name: Configure a web server
  hosts: webservers
  become: true
  vars:
    http_port: 8080
    site_title: Hello from Ansible

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true
        cache_valid_time: 3600

    - name: Create the site directory
      ansible.builtin.file:
        path: /srv/www/site
        state: directory
        mode: "0755"

    - name: Write the home page
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /srv/www/site/index.html
        mode: "0644"

    - name: Write the nginx site configuration
      ansible.builtin.template:
        src: templates/site.conf.j2
        dest: /etc/nginx/conf.d/site.conf
        mode: "0644"
      notify: Restart nginx

    - name: Start nginx and enable it at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

Read it from the top:

  • hosts: webservers applies the play to every host in that inventory group.
  • become: true runs the tasks with sudo, because installing packages and writing to /etc needs root.
  • state: present says "nginx is installed", not "install nginx". cache_valid_time: 3600 refreshes the package lists only if they're more than an hour old.
  • notify: Restart nginx doesn't restart anything by itself. It tells Ansible to run the handler called Restart nginx at the end of the play, but only if this task changed the file.

Indentation matters in YAML: use spaces, never tabs. Ask Ansible to check the playbook's syntax before you run it:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml --syntax-check

playbook: site.yml

Just the playbook's name, and no error, means it parsed correctly.

Step 5: Run the playbook

Run it:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml

PLAY [Configure a web server] **************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Install nginx] ***********************************************************
changed: [localhost]

TASK [Create the site directory] ***********************************************
changed: [localhost]

TASK [Write the home page] *****************************************************
changed: [localhost]

TASK [Write the nginx site configuration] **************************************
changed: [localhost]

TASK [Start nginx and enable it at boot] ***************************************
ok: [localhost]

RUNNING HANDLER [Restart nginx] ************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=7    changed=5    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Installing nginx takes a minute. Each task reports changed if it had to do something, or ok if the host was already in the state it describes. Notice that "Start nginx" says ok: Debian's nginx package starts the service when it's installed, so it was already running. The handler ran because the configuration file was new, and the recap at the bottom counts 5 changes.

Ask the web server for its home page on port 8080:

$ curl http://localhost:8080/
<h1>Hello from Ansible</h1>
<p>Served by server1.</p>

The variables from the playbook and the host name fact have been filled in.

Step 6: Run it again

Now run exactly the same command a second time:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml

PLAY [Configure a web server] **************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Install nginx] ***********************************************************
ok: [localhost]

TASK [Create the site directory] ***********************************************
ok: [localhost]

TASK [Write the home page] *****************************************************
ok: [localhost]

TASK [Write the nginx site configuration] **************************************
ok: [localhost]

TASK [Start nginx and enable it at boot] ***************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Every task says ok, the recap shows changed=0, and the handler didn't run, so nginx wasn't restarted. Each module compared what the server has with what the playbook describes, found nothing to do, and did nothing. This property is called idempotency: you can apply the playbook as often as you like, and it only ever changes what differs.

Step 7: Undo a manual change

Pretend someone fixed something by hand and changed the port in the configuration file:

sudo sed -i 's/listen 8080;/listen 9090;/' /etc/nginx/conf.d/site.conf

Ansible can show what it would change without changing anything. Run the playbook in check mode, with differences shown:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml --check --diff

PLAY [Configure a web server] **************************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Install nginx] ***********************************************************
ok: [localhost]

TASK [Create the site directory] ***********************************************
ok: [localhost]

TASK [Write the home page] *****************************************************
ok: [localhost]

TASK [Write the nginx site configuration] **************************************
--- before: /etc/nginx/conf.d/site.conf
+++ after: /home/labuser/.ansible/tmp/ansible-local-2372cz05cx4d/tmp35qyi3jv/site.conf.j2
@@ -1,6 +1,6 @@
 # Managed by Ansible: changes made here will be overwritten.
 server {
-    listen 9090;
+    listen 8080;
     server_name _;
     root /srv/www/site;
 }

changed: [localhost]

TASK [Start nginx and enable it at boot] ***************************************
ok: [localhost]

RUNNING HANDLER [Restart nginx] ************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=7    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The diff shows the line that drifted from the template: - is what's on the server, + is what the playbook wants. Because this was a check, nothing was changed and nginx wasn't restarted:

$ grep listen /etc/nginx/conf.d/site.conf
    listen 9090;

Now run the playbook for real:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml
...
TASK [Write the nginx site configuration] **************************************
changed: [localhost]

TASK [Start nginx and enable it at boot] ***************************************
ok: [localhost]

RUNNING HANDLER [Restart nginx] ************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=7    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

The configuration is back to the template, and because the file changed, the handler restarted nginx so the change took effect:

$ grep listen /etc/nginx/conf.d/site.conf
    listen 8080;
$ curl http://localhost:8080/
<h1>Hello from Ansible</h1>
<p>Served by server1.</p>

The playbook, not anyone's memory, decides what the configuration is.

Step 8: Change the desired state

To change the server, change the playbook. Give the site a new title by editing the site_title line in site.yml:

sed -i 's/site_title: Hello from Ansible/site_title: Configured by Ansible/' ~/webserver/site.yml

Run the playbook again:

$ cd ~/webserver
$ ansible-playbook -i inventory.ini site.yml
...
TASK [Write the home page] *****************************************************
changed: [localhost]

TASK [Write the nginx site configuration] **************************************
ok: [localhost]

TASK [Start nginx and enable it at boot] ***************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=6    changed=1    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Only the home page changed. The configuration file stayed the same, so the handler didn't run and nginx kept serving without a restart. The new page is live straight away:

$ curl http://localhost:8080/
<h1>Configured by Ansible</h1>
<p>Served by server1.</p>

What you have learned

  • An inventory lists the hosts Ansible manages, in groups; ansible_connection=local manages the machine Ansible runs on.
  • A playbook describes the desired state as tasks, each calling a module such as apt, file, template or service.
  • Templates fill variables and facts into files as Ansible writes them.
  • Handlers run only when a task that notifies them reports a change, so services restart only when they need to.
  • Running a playbook again changes nothing when the server already matches it (changed=0): it's idempotent.
  • --check --diff shows drift without fixing it, and a normal run puts the server back to the described state.

Next steps