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
sudowithout 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:
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:
Create ~/webserver/inventory.ini with this content:
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:
Check that both templates are in place:
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: webserversapplies the play to every host in that inventory group.become: trueruns the tasks withsudo, because installing packages and writing to/etcneeds root.state: presentsays "nginx is installed", not "install nginx".cache_valid_time: 3600refreshes the package lists only if they're more than an hour old.notify: Restart nginxdoesn't restart anything by itself. It tells Ansible to run the handler calledRestart nginxat 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:
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:
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:
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:
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:
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:
What you have learned¶
- An inventory lists the hosts Ansible manages, in groups;
ansible_connection=localmanages the machine Ansible runs on. - A playbook describes the desired state as tasks, each calling a module such as
apt,file,templateorservice. - 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 --diffshows drift without fixing it, and a normal run puts the server back to the described state.
Next steps¶
- Read Idempotency and configuration management to understand why describing state works better than scripting steps, and how push tools like Ansible compare with pull-based tools.
- Keep
~/webserverin a Git repository, so every change to your server's configuration has a history. - Learn to schedule jobs on a server with How to schedule a job with a systemd timer.
Related¶
- Explanation: Idempotency and configuration management
- Explanation: cron and systemd timers
- How-to: How to write a safe shell script
- How-to: How to schedule a job with cron
- How-to: How to schedule a job with a systemd timer
- Reference: crontab syntax
- Reference: systemd timer directives