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) and web2 (web2_server_ip).
  • A non-root user with sudo privileges 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:

PathPurpose
ansible.cfgProject defaults
inventory.iniServers and groups
group_vars/all.ymlVariables for every server
group_vars/webservers.ymlVariables for the web servers
templates/nginx-site.conf.j2Nginx virtual host template
files/site/Static site content to deploy
baseline.yml, webserver.yml, deploy.ymlThe three playbooks
site.ymlEntry 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 name in the apt module 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, so 10-hardening.conf wins over the 50-cloud-init.conf file that some images ship with PasswordAuthentication yes.
  • validate runs sshd -t against the new file before replacing the real one. If the syntax is wrong, the task fails and the old configuration stays in place.

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 inside files/, and the trailing slash copies the folder's contents rather than the folder itself.
  • mode: u=rwX,g=rX,o=rX gives files 0644 and directories 0755.
  • The uri task requests the site from the server itself with the right Host header 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.