--- title: "Practical Ansible Playbook Examples for Ubuntu 24.04" description: "Build a real Ansible project for Ubuntu 24.04 servers: a security baseline, an Nginx playbook with templates and handlers, and a zero-downtime rolling deploy." category: "Automation Tools" language: en source: https://cubepath.com/docs/automation-tools/ansible-playbooks-practical-examples updated: 2026-09-25 --- # Practical Ansible Playbook Examples for Ubuntu 24.04 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/.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: ```bash 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: ```bash nano inventory.ini ``` ```ini [webservers] web1 ansible_host=web1_server_ip web2 ansible_host=web2_server_ip [all:vars] ansible_user=your_user ``` Create the configuration file: ```bash nano ansible.cfg ``` ```ini [defaults] inventory = inventory.ini interpreter_python = auto_silent [ssh_connection] pipelining = True ``` Check that both servers respond: ```bash 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: ```bash nano group_vars/all.yml ``` ```yaml --- server_timezone: Etc/UTC base_packages: - curl - htop - vim - unattended-upgrades ``` Then the variables for the web servers: ```bash nano group_vars/webservers.yml ``` ```yaml --- server_name: your_domain web_root: /var/www/your_domain ``` Confirm that Ansible merges both files for `web1`: ```bash ansible-inventory --host web1 ``` ```output { "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. ```bash nano baseline.yml ``` ```yaml --- - 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. > Warning: This 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: ```bash ansible-playbook baseline.yml -K ``` On the first run, the handler runs after all tasks, once per host: ```output 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: ```bash ssh -t your_user@web1_server_ip "sudo sshd -T | grep -i passwordauthentication" ``` ```output 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: ```bash nano templates/nginx-site.conf.j2 ``` ```nginx # 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: ```bash nano webserver.yml ``` ```yaml --- - 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: ```bash ansible-playbook webserver.yml -K ``` ```output 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: ```bash 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: ```bash nano files/site/index.html ``` ```html your_domain

Release 1

``` Create the deployment playbook: ```bash nano deploy.yml ``` ```yaml --- - 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: ```bash ansible-playbook deploy.yml -K ``` The output shows two separate plays, one per server: ```output 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: ```bash curl -H "Host: your_domain" http://web2_server_ip/ ``` ```output ...

Release 1

... ``` 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`: ```bash nano site.yml ``` ```yaml --- - 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: ```bash ansible-playbook site.yml -K ``` ```bash ansible-playbook site.yml -K --tags web ``` ```bash 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: ```bash 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.