Short playbooks that install one package are a good start, but real infrastructure needs a structure you can grow: shared variables, templates, handlers that restart services only when needed, and deployments that never take every server down at once. In this tutorial you will build a small but complete Ansible project for Ubuntu 24.04 servers with three playbooks: a security baseline, an Nginx web server configuration and a rolling deployment of site content, all tied together by a single site.yml.
Prerequisites
To follow this guide you need:
- A control node with Ansible installed and SSH key access to your servers, as described in the guide How to Install and Use Ansible on Ubuntu 24.04.
- Two managed servers running Ubuntu 24.04, for example CubePath VPS instances, called
web1(web1_server_ip) andweb2(web2_server_ip). - A non-root user with
sudoprivileges on both servers (your_user), who can already log in with an SSH key. - A domain name,
your_domain, if you want to reach the site by name. An IP address works for testing.
Step 1 - Creating the project structure
A predictable layout makes the project easy to navigate and lets Ansible find variables and templates automatically. Ansible loads group_vars/<group>.yml for every host in that group, and looks up template and copy sources in templates/ and files/ next to the playbook.
Create the directories:
mkdir -p ~/ansible-web/{group_vars,templates,files/site}
cd ~/ansible-web
The finished project will look like this:
| Path | Purpose |
|---|---|
ansible.cfg | Project defaults |
inventory.ini | Servers and groups |
group_vars/all.yml | Variables for every server |
group_vars/webservers.yml | Variables for the web servers |
templates/nginx-site.conf.j2 | Nginx virtual host template |
files/site/ | Static site content to deploy |
baseline.yml, webserver.yml, deploy.yml | The three playbooks |
site.yml | Entry point that runs all of them |
Create the inventory:
nano inventory.ini
[webservers]
web1 ansible_host=web1_server_ip
web2 ansible_host=web2_server_ip
[all:vars]
ansible_user=your_user
Create the configuration file:
nano ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
Check that both servers respond:
ansible all -m ping
Both hosts should return "ping": "pong".
Step 2 - Defining shared variables
Variables keep values that change between projects out of the playbooks. Create the variables that apply to every server:
nano group_vars/all.yml
---
server_timezone: Etc/UTC
base_packages:
- curl
- htop
- vim
- unattended-upgrades
Then the variables for the web servers:
nano group_vars/webservers.yml
---
server_name: your_domain
web_root: /var/www/your_domain
Confirm that Ansible merges both files for web1:
ansible-inventory --host web1
{
"ansible_host": "web1_server_ip",
"ansible_user": "your_user",
"base_packages": [
"curl",
"htop",
"vim",
"unattended-upgrades"
],
"server_name": "your_domain",
"server_timezone": "Etc/UTC",
"web_root": "/var/www/your_domain"
}
Step 3 - Writing a baseline playbook
The baseline playbook applies the settings every server should have: common packages, a timezone, SSH hardening and a firewall. It introduces two important features: validate, which tests a configuration file before it is put in place, and handlers, which restart a service only when its configuration actually changed.
nano baseline.yml
---
- name: Apply the server baseline
hosts: all
become: true
tasks:
- name: Install base packages
ansible.builtin.apt:
name: "{{ base_packages }}"
state: present
update_cache: true
cache_valid_time: 3600
- name: Set the timezone
community.general.timezone:
name: "{{ server_timezone }}"
- name: Harden the SSH daemon
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/10-hardening.conf
content: |
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart SSH
- name: Allow SSH through UFW
community.general.ufw:
rule: allow
name: OpenSSH
- name: Enable UFW with a default deny policy
community.general.ufw:
state: enabled
policy: deny
direction: incoming
handlers:
- name: Restart SSH
ansible.builtin.systemd_service:
name: ssh
state: restarted
Some details worth knowing:
- Passing a list to
namein theaptmodule installs all packages in one transaction, which is much faster than a loop. - The SSH settings go in a drop-in file under
/etc/ssh/sshd_config.d/. sshd uses the first value it reads for each option, and files are read in alphabetical order, so10-hardening.confwins over the50-cloud-init.conffile that some images ship withPasswordAuthentication yes. validaterunssshd -tagainst the new file before replacing the real one. If the syntax is wrong, the task fails and the old configuration stays in place.
WarningThis playbook disables SSH password logins. Make sure your SSH key works for every user who needs access before running it.
Run it. -K asks for your sudo password:
ansible-playbook baseline.yml -K
On the first run, the handler runs after all tasks, once per host:
RUNNING HANDLER [Restart SSH] **************************************************
changed: [web1]
changed: [web2]
PLAY RECAP *********************************************************************
web1 : ok=7 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=7 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Verify that password logins are now disabled on a server:
ssh -t your_user@web1_server_ip "sudo sshd -T | grep -i passwordauthentication"
passwordauthentication no
Run the playbook a second time. Every task reports ok, and the handler does not run, because nothing changed.
Step 4 - Configuring Nginx with a template and handlers
Templates let one file serve every server: Ansible renders the Jinja2 placeholders with each host's variables. Create the virtual host template:
nano templates/nginx-site.conf.j2
# Managed by Ansible. Local changes will be overwritten.
server {
listen 80;
listen [::]:80;
server_name {{ server_name }};
root {{ web_root }};
index index.html;
access_log /var/log/nginx/{{ server_name }}.access.log;
error_log /var/log/nginx/{{ server_name }}.error.log;
location / {
try_files $uri $uri/ =404;
}
}
Now create the web server playbook:
nano webserver.yml
---
- name: Configure Nginx web servers
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Create the web root
ansible.builtin.file:
path: "{{ web_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Deploy the virtual host
ansible.builtin.template:
src: nginx-site.conf.j2
dest: "/etc/nginx/sites-available/{{ server_name }}.conf"
owner: root
group: root
mode: "0644"
notify: Reload Nginx
- name: Enable the virtual host
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ server_name }}.conf"
dest: "/etc/nginx/sites-enabled/{{ server_name }}.conf"
state: link
notify: Reload Nginx
- name: Disable the default site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload Nginx
- name: Allow HTTP and HTTPS through UFW
community.general.ufw:
rule: allow
port: "{{ item }}"
proto: tcp
loop:
- "80"
- "443"
- name: Ensure Nginx is running and enabled
ansible.builtin.systemd_service:
name: nginx
state: started
enabled: true
handlers:
- name: Test Nginx configuration
ansible.builtin.command: nginx -t
changed_when: false
listen: Reload Nginx
- name: Reload Nginx service
ansible.builtin.systemd_service:
name: nginx
state: reloaded
listen: Reload Nginx
Three tasks notify Reload Nginx, but the handlers still run only once per host at the end of the play. Both handlers listen to the same topic and run in the order they are defined, so nginx -t checks the complete configuration first. If the test fails, the play fails on that host and the reload never happens, so a broken template cannot take the running site down.
Run the playbook:
ansible-playbook webserver.yml -K
RUNNING HANDLER [Test Nginx configuration] *************************************
ok: [web1]
ok: [web2]
RUNNING HANDLER [Reload Nginx service] *****************************************
changed: [web1]
changed: [web2]
Check the rendered file on one server:
ansible web1 -b -K -m ansible.builtin.command -a "cat /etc/nginx/sites-enabled/your_domain.conf"
The output shows the template with server_name and root filled in.
Step 5 - Rolling out site content without downtime
When you deploy to all servers at once, a bad release breaks every server at the same moment. The serial keyword makes Ansible run the whole play on a batch of hosts before moving on to the next batch. Combined with a health check and max_fail_percentage: 0, the rollout stops at the first server that fails, and the remaining servers keep serving the previous version.
Create a simple page to deploy:
nano files/site/index.html
<!DOCTYPE html>
<html>
<head><title>your_domain</title></head>
<body><h1>Release 1</h1></body>
</html>
Create the deployment playbook:
nano deploy.yml
---
- name: Deploy site content one server at a time
hosts: webservers
become: true
serial: 1
max_fail_percentage: 0
tasks:
- name: Copy site files
ansible.builtin.copy:
src: site/
dest: "{{ web_root }}/"
owner: www-data
group: www-data
mode: u=rwX,g=rX,o=rX
- name: Check that the site answers with HTTP 200
ansible.builtin.uri:
url: http://127.0.0.1/
headers:
Host: "{{ server_name }}"
status_code: 200
register: health
until: health.status == 200
retries: 5
delay: 3
src: site/is resolved insidefiles/, and the trailing slash copies the folder's contents rather than the folder itself.mode: u=rwX,g=rX,o=rXgives files0644and directories0755.- The
uritask requests the site from the server itself with the rightHostheader and retries up to five times before failing.
If the servers sit behind a load balancer, this is also where you would take each server out of the pool before copying and add it back after the health check.
Run the deployment:
ansible-playbook deploy.yml -K
The output shows two separate plays, one per server:
PLAY [Deploy site content one server at a time] ********************************
TASK [Copy site files] *********************************************************
changed: [web1]
TASK [Check that the site answers with HTTP 200] *******************************
ok: [web1]
PLAY [Deploy site content one server at a time] ********************************
TASK [Copy site files] *********************************************************
changed: [web2]
...
Verify from the control node:
curl -H "Host: your_domain" http://web2_server_ip/
...
<body><h1>Release 1</h1></body>
...
To publish a new release, edit files/site/index.html and run deploy.yml again.
Step 6 - Tying everything together with site.yml
A single entry point makes it easy to build a new server from scratch or re-apply everything. Create site.yml:
nano site.yml
---
- name: Baseline
ansible.builtin.import_playbook: baseline.yml
tags: baseline
- name: Web servers
ansible.builtin.import_playbook: webserver.yml
tags: web
- name: Deploy
ansible.builtin.import_playbook: deploy.yml
tags: deploy
Tags on import_playbook apply to every task inside that playbook, so you can run only part of the project. Some common invocations:
ansible-playbook site.yml -K
ansible-playbook site.yml -K --tags web
ansible-playbook site.yml -K --limit web2
The first runs everything, the second only the Nginx configuration, and the third applies everything to web2 only, for example after adding a new server to the inventory.
Before changing production, preview the effect with check mode and diffs:
ansible-playbook site.yml -K --check --diff
--diff shows the exact lines that would change in templated and copied files.
Troubleshooting
Could not find or access 'nginx-site.conf.j2': Ansible looks for templates in a templates/ directory next to the playbook. Run the command from the project root and keep playbooks and templates/ in the same folder.
The SSH handler ran but password logins still work: another file in /etc/ssh/sshd_config.d/ sorts before 10-hardening.conf, or /etc/ssh/sshd_config sets the option before its Include line. Check the effective value with sudo sshd -T | grep -i passwordauthentication.
Test Nginx configuration fails: run sudo nginx -t on the server to see the exact line, fix the template and run the playbook again. The previous configuration is still active because the reload was skipped.
The health check fails with Status code was 404: the Host header does not match server_name, so Nginx serves another site, or web_root has no index.html. Check group_vars/webservers.yml and the content of files/site/.
Conclusion
You built an Ansible project with shared variables, a baseline playbook that validates its changes, an Nginx playbook driven by a template and handlers, and a rolling deployment that stops at the first failed health check. Each playbook is idempotent and can be run on its own or through site.yml.
As next steps, turn each playbook into a role with ansible-galaxy role init, store secrets such as API tokens with ansible-vault, and add HTTPS with Certbot once your domain points to the servers.
