Automate a Web Server Fleet with Ansible
Provision and configure two Ubuntu VMs with a single Ansible command.
Introduction
30 Second Summary
Imagine you need to set up 10 web servers. You could log into each one, type the same commands, and hope you don't make a typo on server number 7. Or you could write your instructions once and let a tool handle the rest.
In this project, you will use Ansible to automate a fleet of web servers running on your Mac. You will write a single YAML playbook and watch it configure two virtual machines simultaneously, installing software and deploying a custom web page with one command.
What You'll Build
You will run one command in your terminal and watch Ansible reach into two Ubuntu virtual machines at the same time, install Nginx on each, and deploy a custom web page. Then you will open your browser, visit each VM's IP address, and see your page live.
By the end of this project, you'll have:
- A single command that configures both servers identically in parallel. You will type it once and watch Ansible install packages, start services, and deploy files across your entire fleet.
- A live "Hello from Ansible" web page on each VM that you can visit in your browser by IP address, proving the deployment worked.
- Proof of idempotency in action. Re-run the same command and watch Ansible skip everything because the servers are already in the desired state.
- Secret Mission: Deploy different content to each server using Jinja2 templates and host variables, the professional pattern used in production.
Are there any prerequisites?
None. This project includes setup instructions for every tool you need. You just need a Mac with a terminal.
Before We Start
Before we dive in, let's lock in what you're about to build and why it matters.
Set Up Your Tools
To automate servers with Ansible, you need two things on your Mac: Ansible itself (the automation engine) and some machines for it to manage.
In this step you will install four tools. Homebrew is the package manager that makes installing everything else easy. Python 3.12 is the runtime Ansible requires. Ansible is the automation engine. Multipass creates lightweight Ubuntu virtual machines on your Mac so you have real servers to practice on.
In this step, get ready to:
- Install Homebrew and Python 3.12.
- Install Ansible and Multipass.
- Create a project directory and generate an SSH key.
Install Homebrew and Python
Homebrew is a package manager for macOS that lets you install developer tools from the command line. You will use it to install Python, Multipass, and pipx.
- Press Cmd+Space (macOS) to open Spotlight search.
- Type Terminal and press Enter to open it.
- Install Homebrew by running this command:
/bin/bash -c "$(curl -fsSL https://raw.githubusercontent.com/Homebrew/install/HEAD/install.sh)"
What does this command do?
This downloads and runs Homebrew's official installer script. It will ask for your Mac password and show you what it plans to install before proceeding.
The installer may prompt you to press Enter to continue. Follow any on-screen instructions, including any "Next steps" it prints at the end about adding Homebrew to your PATH.
- Confirm Homebrew is working by running:
brew --version
You should see a version number printed, confirming Homebrew is ready.
Command not found after installing Homebrew?
If you see command not found: brew, the installer printed "Next steps" at the end with commands to add Homebrew to your PATH. Run those commands, then close and reopen Terminal.
Still stuck? Help me fix my Homebrew PATH issue
- Install Python 3.12 by running:
brew install python@3.12
- Verify the installation by running:
python3.12 --version
✔️ I see Python 3.12.x
Python is installed and ready. You will use this as the runtime for Ansible.
⨯ Command not found
Homebrew may not have linked the binary to your PATH. Try running:
brew link python@3.12
Then run python3.12 --version again. If it still does not work, close and reopen Terminal to reload your PATH.
Why Python 3.12?
Ansible-core 2.21 requires Python 3.12 or newer on the control node (your Mac). macOS does not ship with a compatible Python version, so you install one explicitly through Homebrew.
Install Ansible and Multipass
Ansible is distributed as a Python package. The recommended way to install it is through pipx, a tool that installs Python applications in isolated environments so they do not conflict with each other.
- Install pipx by running:
brew install pipx
pipx ensurepath
What does pipx ensurepath do?
This adds the directory where pipx places installed executables to your shell's PATH. Without it, your terminal would not find the ansible command after installation.
- Close Terminal completely and reopen it so the updated PATH takes effect.
- Install Ansible by running:
pipx install --include-deps ansible
This takes a minute or two. When it finishes, you will see a confirmation that the ansible commands are now available.
- Verify Ansible is installed by running:
ansible --version
✔️ I see version info with ansible-core 2.21
Ansible is installed and ready. The output shows the ansible-core version and configuration details.
⨯ Command not found
Your terminal cannot find the ansible binary. This usually means the PATH update from pipx ensurepath has not taken effect yet.
- Close Terminal completely and reopen it.
- Run ansible --version again.
If it still does not work, run pipx ensurepath one more time and restart Terminal.
- Install Multipass by running:
brew install --cask multipass
What is Multipass?
Multipass is a lightweight VM manager from Canonical that creates Ubuntu virtual machines on your Mac. Each VM behaves like a real server with its own IP address, SSH access, and package manager.
This makes it perfect for practicing Ansible against real targets without needing cloud accounts.
- Verify Multipass is installed by running:
multipass version
✔️ I see multipass and multipassd version numbers
Multipass is installed. The output shows both the client and daemon versions.
⨯ Command not found or permission error
Multipass installs as a macOS application that also provides a CLI. If the command is not found:
- Open System Settings, select Privacy & Security, and check if macOS blocked Multipass. Allow it if prompted.
- Close and reopen Terminal, then run multipass version again.
Still stuck? Help me troubleshoot Multipass installation
Create a project directory and generate an SSH key
Ansible connects to servers over SSH. Instead of using your personal SSH key, you will create a dedicated key pair just for this project. This keeps your Ansible access separate from any other SSH keys you use.
- Create your project folder and move into it by running:
mkdir ~/ansible-fleet && cd ~/ansible-fleet
- Confirm you are in the right directory by running:
pwd
You should see /Users/yourname/ansible-fleet (with your actual username).
- Generate a dedicated SSH key pair (with no passphrase) by running:
ssh-keygen -t ed25519 -f ~/.ssh/ansible_key -N ""
What does this command do?
- -t ed25519 creates a modern, secure Ed25519 key.
- -f ~/.ssh/ansible_key saves the key to a specific file instead of the default location.
- -N "" sets an empty passphrase so Ansible can use the key without prompting you.
- Verify the key files were created by running:
ls ~/.ssh/ansible_key*
You should see two files: ansible_key (private) and ansible_key.pub (public).
Seeing a file already exists error?
If you previously ran this command, the key already exists. You can safely overwrite it by running the same command and typing y when prompted. This is a learning project, so replacing the key causes no harm.
Still need help? Help me resolve my SSH key generation issue
Before you run the final check, what do you expect ansible --version and multipass version to show now that everything is installed?
- Run your end-of-step verification:
ansible --version && multipass version
Both tools print version information without errors. Ansible shows its configuration file path and Python version. Multipass shows client and daemon version numbers.
Your Mac is now a fully equipped Ansible control node with target machines ready to spin up. Next up, you will launch your server fleet and give Ansible something to manage.
Launch Your Server Fleet
Your tools are installed and your SSH key is ready. Now Ansible needs actual machines to manage.
In this step, you'll create two Ubuntu virtual machines using Multipass. These VMs simulate real servers that behave just like production Linux boxes. After this step you'll have a visible fleet, but no way to configure them at scale. That gap is exactly what Ansible will fill in the next step.
In this step, get ready to:
- Launch two Ubuntu VMs using Multipass.
- Add your SSH key to both VMs for passwordless authentication.
- Verify SSH connectivity from your Mac to each VM.
Launch two Ubuntu VMs
Multipass creates lightweight Ubuntu VMs on your Mac. Each VM gets its own IP address, its own filesystem, and its own SSH daemon. They behave like cloud instances you'd spin up on AWS or GCP.
- Launch your first VM by running this command:
multipass launch --name web1 --cpus 1 --memory 512M --disk 5G
What does this command do?
- multipass launch creates a new Ubuntu VM from the latest LTS image.
- --name web1 gives the VM a memorable name you'll use in Ansible later.
- --cpus 1 --memory 512M --disk 5G keeps the VM lightweight so your Mac stays responsive.
This command downloads an Ubuntu image on first run, so it may take 1-2 minutes. You'll see Launched: web1 when it finishes.
- Launch your second VM by running this command:
multipass launch --name web2 --cpus 1 --memory 512M --disk 5G
This one launches faster since the Ubuntu image is already cached.
- Confirm both VMs are running by running this command:
multipass list
You should see both web1 and web2 listed with a Running state and an IPv4 address next to each.
VM not launching?
- If you see a timeout error, your Mac may need to allow Multipass in System Settings > Privacy & Security. Look for a blocked item and click Allow, then retry the launch command.
- If you see an out-of-disk error, try reducing --disk to 3G.
- help me troubleshoot my Multipass launch error
Add your SSH key to both VMs
Ansible connects to servers over SSH. For passwordless access, each VM needs a copy of your public key in its authorized_keys file.
- Push your public key to web1 by running this command:
multipass exec web1 -- bash -c "cat >> /home/ubuntu/.ssh/authorized_keys" < ~/.ssh/ansible_key.pub
- Push your public key to web2 by running this command:
multipass exec web2 -- bash -c "cat >> /home/ubuntu/.ssh/authorized_keys" < ~/.ssh/ansible_key.pub
What does this command do?
multipass exec runs a command inside the VM. The bash -c "cat >> ..." part appends your public key to the VM's authorized keys file.
This is the same pattern you'd use when setting up SSH access to a cloud server. Once the key is in place, Ansible can authenticate without a password.
Both commands run silently on success. You'll prove they worked by testing SSH in the next substep.
Test SSH connectivity manually
Before handing control to Ansible, you should verify that your Mac can SSH into each VM directly. This confirms the key deployment worked.
- Get the IP addresses of your VMs by running this command:
multipass list
- Note the IPv4 address next to web1 in the output. You'll use it in the next command.
Before you run the next command, what do you expect to see if your SSH key was deployed correctly?
- Test SSH to web1 by running this command (replace the IP with your web1 IP from the output above):
ssh -i ~/.ssh/ansible_key -o StrictHostKeyChecking=no ubuntu@[[WEB1_IP="your web1 IPv4 address"]] "hostname"
The command prints web1 and returns you to your Mac's terminal. That single word proves your Mac can reach into the VM over SSH using your dedicated key.
Why test manually first?
If SSH fails here, you know the problem is with key deployment or network access, not with Ansible configuration. Isolating variables makes debugging faster.
Seeing 'Permission denied'?
- Make sure you're using ~/.ssh/ansible_key (the private key), not ~/.ssh/ansible_key.pub (the public key).
- Verify the key was actually pushed by running multipass exec web1 -- cat /home/ubuntu/.ssh/authorized_keys. You should see your key printed out.
- If the file is empty, re-run the key push command from the previous substep.
- help me fix SSH permission denied when connecting to my Multipass VM
Your fleet is running and you have direct SSH access to both servers. Next up, you'll connect Ansible to this fleet so you can manage both machines with a single command instead of SSHing in one at a time.
Connect Ansible to Your Fleet
Your VMs are running and you can SSH into them manually. But Ansible has no idea they exist.
Ansible needs two files to manage your fleet: an inventory file that lists the machines and how to connect, and a configuration file that sets default behaviour. Without these, any Ansible command you try will fail because it has no targets. In this step, you will create both files and prove Ansible can reach your VMs.
In this step, get ready to:
- Create an inventory file listing your VMs and connection details.
- Create an Ansible configuration file with sensible defaults.
- Test that Ansible can reach both VMs with a single command.
Create the inventory file
An Ansible inventory is the file that tells Ansible which machines it manages. It groups hosts together and stores connection details like usernames and SSH key paths.
- Press Cmd+Space (macOS) to open your search bar.
- Type VS Code and press Enter to open it.
- Click File in the top menu bar.
- Click Open Folder.
- Navigate to your home folder, select the ansible-fleet folder, then click Open.
- Create a new file by clicking File then New File.
- Save it immediately as inventory.ini by pressing Cmd+S and typing the filename.
- Paste the following content into inventory.ini:
[webservers]
web1 ansible_host=[[WEB1_IP="your web1 IPv4 address"]] server_role=primary
web2 ansible_host=[[WEB2_IP="your web2 IPv4 address"]] server_role=replica
[webservers:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/ansible_key
What does this file do?
The [webservers] line creates a group. Any host listed beneath it belongs to that group, so you can target all of them at once.
Each host line sets ansible_host to the VM's IP address and assigns a server_role variable you will use in a later step.
The [webservers:vars] section applies shared settings to every host in the group: connect as the ubuntu user and authenticate with your dedicated SSH key.
Finding your VM IPs
If you forgot the IP addresses, switch to your terminal and run multipass list. The IPv4 column shows the address for each VM.
- Save inventory.ini with Cmd+S.
Not sure which IP to use?
Use the IPv4 address from multipass list (it looks like 192.168.x.x). Do not use 127.0.0.1 or localhost.
Help me find my VM IP addresses for the Ansible inventory
Create ansible.cfg
Ansible looks for a config file in your project directory. This file sets defaults so you do not have to type them into every command.
- Create another new file in VS Code (File then New File).
- Save it as ansible.cfg with Cmd+S.
- Paste the following content into ansible.cfg:
[defaults]
inventory = inventory.ini
host_key_checking = False
What does this config do?
The inventory setting points Ansible at your inventory file automatically. Without it, you would need to pass -i inventory.ini on every command.
Setting host_key_checking = False tells Ansible to skip SSH host key verification. This is safe for local VMs you just created. In a production environment you would leave this enabled.
- Save ansible.cfg with Cmd+S.
- Confirm both files exist by switching to your terminal and running:
ls
You should see ansible.cfg and inventory.ini listed in the output.
Don't see both files?
Make sure your terminal is in the ansible-fleet folder. Run cd ~/ansible-fleet if you are somewhere else. Also check that you saved each file with the exact names inventory.ini and ansible.cfg (no extra extensions like .txt).
Help me find my inventory and config files
✔️ Awesome, I've got everything!
Great. Double check you have saved both files before moving on.
ⓧ I'd like to double check the full code
Here is inventory.ini:
[webservers]
web1 ansible_host=[[WEB1_IP="your web1 IPv4 address"]] server_role=primary
web2 ansible_host=[[WEB2_IP="your web2 IPv4 address"]] server_role=replica
[webservers:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/ansible_key
Here is ansible.cfg:
[defaults]
inventory = inventory.ini
host_key_checking = False
Test connectivity with an ad-hoc command
Ansible's ping module checks whether Ansible can connect to each host and execute Python. It is the standard way to verify your inventory and SSH setup work together.
Before you run this, what do you think will happen? Will Ansible successfully reach both VMs, or will one of them fail?
- Test Ansible's connectivity to your fleet by running:
ansible all -m ping
Both hosts respond with SUCCESS and "ping": "pong" in green text. This proves Ansible can reach your VMs over SSH, authenticate with your key, and execute commands.
What does this command do?
The all keyword targets every host in your inventory. The -m ping flag tells Ansible to run its built-in ping module, which connects via SSH and checks that Python is available on the remote machine.
This is not the same as a network ping. It is an Ansible-specific connectivity test that validates your entire authentication chain.
- Try a second ad-hoc command to see how long each VM has been running:
ansible all -m command -a "uptime"
Each host returns its uptime, confirming Ansible can run arbitrary commands across your fleet from a single line.
Seeing UNREACHABLE or permission denied?
Check that the IP addresses in inventory.ini match the output of multipass list. A typo in the IP is the most common cause.
If you see a permission denied error, verify the SSH key path in your inventory matches ~/.ssh/ansible_key (not ansible_key.pub). Ansible needs the private key, not the public one.
Help me fix my Ansible connectivity error
You now have Ansible fully connected to your server fleet. One command reaches both machines simultaneously. Next up, you will write a playbook that installs and configures Nginx across your entire fleet in a single run.
Write and Run Your First Playbook
In the last step, you ran ad-hoc commands to ping your VMs. That works for one-off checks, but imagine doing that for 50 servers, or needing to repeat the same setup tomorrow. Ad-hoc commands are not repeatable or version-controllable.
A playbook solves this. You declare your desired state in a YAML file, and Ansible figures out how to make it happen. This is the core infrastructure-as-code pattern: describe what you want, run one command, and both servers get configured identically.
In this step, get ready to:
- Write a YAML playbook that installs and configures Nginx on both VMs.
- Run the playbook and verify your custom web page is live.
- Re-run the same playbook and observe idempotency in action.
Create the deploy.yml playbook
Your playbook will do three things: install Nginx, start the Nginx service, and deploy a custom HTML page. Each of these is a separate task in the playbook.
- Create a new file called deploy.yml in your ~/ansible-fleet folder by running:
touch deploy.yml
- Open deploy.yml in VS Code (or your preferred text editor) and paste the following play header and first two tasks:
---
- name: Deploy web servers
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Start and enable Nginx
ansible.builtin.service:
name: nginx
state: started
enabled: true
What does this code do?
- hosts: webservers tells Ansible to run this play against every host in your [webservers] inventory group.
- become: true enables privilege escalation (sudo), which is required for installing packages and writing to system directories.
- The first task uses the ansible.builtin.apt module to install Nginx. The update_cache: true flag refreshes the package list first.
- The second task uses ansible.builtin.service to start Nginx and ensure it starts automatically on boot.
- Now add the third task directly below the service task (still inside the tasks: list):
- name: Deploy custom web page
ansible.builtin.copy:
content: |
<!DOCTYPE html>
<html>
<head><title>{{ inventory_hostname }}</title></head>
<body>
<h1>Hello from Ansible!</h1>
<p>Server: {{ inventory_hostname }}</p>
<p>Role: {{ server_role }}</p>
</body>
</html>
dest: /var/www/html/index.html
mode: "0644"
What does this task do?
The ansible.builtin.copy module writes content directly to a file on the managed node. The content: parameter holds inline HTML instead of referencing a separate file.
The {{ inventory_hostname }} and {{ server_role }} are Jinja2 variables. Ansible renders them per-host, so each VM gets a unique page showing its own hostname and role.
- Save deploy.yml.
- Confirm the file exists by running:
ls deploy.yml
You should see deploy.yml printed in the output.
Don't see the file?
- Make sure you are in the ~/ansible-fleet directory. Run pwd to check.
- If you saved the file in a different location, move it here with mv or recreate it in the correct folder.
Why can't Ansible find my playbook file?
✔️ Awesome, I've got everything!
Great. Make sure you have saved deploy.yml before moving on.
ⓧ I'd like to double check the full code
Your deploy.yml should have two sections you built above, in this order:
- The play header (name, hosts, become) plus the Install Nginx and Start and enable Nginx tasks.
- The Deploy custom web page task with the inline HTML content containing {{ inventory_hostname }} and {{ server_role }}.
Scroll up and confirm both chunks are present, properly indented under tasks:, with no extra blank lines between them beyond one.
Run the playbook
Your playbook is ready. Before you run it, think about what you expect to happen. Ansible will connect to both VMs over SSH, install a package, start a service, and write a file.
- What do you think the output will look like? Will both hosts succeed, or will one fail? Make a prediction before you run the command below.
- Execute your playbook by running:
ansible-playbook deploy.yml
Watch the output scroll by. Ansible connects to both VMs in parallel, runs each task, and reports back. At the bottom you will see a PLAY RECAP showing changed=3 for both hosts and zero failures.
What should I see?
The PLAY RECAP at the bottom summarises what happened on each host. changed=3 means Ansible made three changes: installed Nginx, started the service, and wrote the HTML file. failed=0 confirms nothing went wrong.
- Now open your browser and visit the IP address of web1. To find the IP, run:
multipass list
- Copy the IPv4 address for web1 and paste it into your browser's address bar (e.g. http://192.168.64.2).
- You should see a page that says Hello from Ansible! with the server name and role displayed below it.
- Visit web2's IP address in a separate tab to confirm it also shows the page with its own hostname.
Page not loading in the browser?
- Make sure you are using http:// not https:// in the URL. Nginx is not configured for HTTPS.
- Confirm the VMs are still running with multipass list. Both should show Running status.
- If the PLAY RECAP showed failures, read the error message. A common cause is a typo in the YAML indentation.
Help me troubleshoot why my Nginx page is not loading.
Run it again and observe idempotency
Your playbook worked. Both servers are configured and serving pages. Now comes the question that separates infrastructure-as-code from simple scripting: what happens if you run the exact same command again?
- Before you run it, predict: will Ansible reinstall Nginx? Will it overwrite the HTML file? Or will something different happen?
- Run the same playbook command again:
ansible-playbook deploy.yml
This time the PLAY RECAP shows changed=0 for both hosts. Ansible checked each task, found everything already matched the desired state, and made zero changes.
What is idempotency?
Idempotency means running the same operation multiple times produces the same result as running it once. Ansible checks the current state before acting. If Nginx is already installed, it skips the install. If the file already has the right content, it skips the write.
This is the defining property of infrastructure as code. You can safely re-run your playbook after every change without worrying about breaking what already works.
You now have a working playbook that installs, configures, and deploys across two servers with a single command. Next up, you will organise your fleet with groups and variables so each server can have its own identity.
Scale with Groups and Variables
Your playbook now deploys Nginx and a custom page to both servers in one shot. That is powerful, but every server gets treated identically.
In real platform engineering, servers carry different roles. A web tier, a database tier, a cache layer. You need a way to customize configuration per host without duplicating playbooks. Ansible solves this with host variables and group variables stored directly in your inventory.
In this step, get ready to:
- Understand how host variables in your inventory customize per-server behavior.
- Trace how Jinja2 templates in the playbook render different content per host.
- Verify that each VM serves a unique page from a single playbook.
Review the host variables in your inventory
Your inventory already assigns a unique server_role variable to each host. This is the mechanism that lets a single playbook produce different results on different machines.
- Open inventory.ini in VS Code.
- Locate the two host lines under [webservers]:
[webservers]
web1 ansible_host=REPLACE_WITH_WEB1_IP server_role=primary
web2 ansible_host=REPLACE_WITH_WEB2_IP server_role=replica
What are host variables?
Each key=value pair after the hostname becomes a variable scoped to that specific host. When Ansible connects to web1, it sees server_role as primary. When it connects to web2, the same variable resolves to replica.
The [webservers:vars] section below sets shared variables (like ansible_user and ansible_ssh_private_key_file) that apply to every host in the group. Host-level variables override group-level ones when there is a conflict.
- Confirm your variables resolve correctly by running this ad-hoc command:
ansible all -m debug -a "var=server_role"
You should see web1 reporting primary and web2 reporting replica.
Seeing 'UNDEFINED' for server_role?
Check that each host line in inventory.ini has server_role= with no spaces around the equals sign. Ansible treats spaces in key=value pairs literally, which can break variable assignment.
help me debug undefined variables in my Ansible inventory
Trace how variables customize the web page
The playbook uses Jinja2 template syntax to inject host variables into the HTML content. This is how a single task produces different output on each server.
- In deploy.yml, find the Deploy custom web page task and look at the content parameter:
- name: Deploy custom web page
ansible.builtin.copy:
content: |
<!DOCTYPE html>
<html>
<head><title>{{ inventory_hostname }}</title></head>
<body>
<h1>Hello from Ansible!</h1>
<p>Server: {{ inventory_hostname }}</p>
<p>Role: {{ server_role }}</p>
</body>
</html>
dest: /var/www/html/index.html
mode: "0644"
How does Jinja2 rendering work here?
Anything inside {{ }} is a Jinja2 expression. Ansible evaluates it before sending the content to the managed node.
When Ansible processes web1, it replaces {{ inventory_hostname }} with web1 and {{ server_role }} with primary. For web2, the same template renders web2 and replica. One playbook, two different pages.
Run the playbook and verify per-host pages
Time to confirm that both servers display unique content generated from your single playbook.
Before you run the playbook, predict what the PLAY RECAP will show. Will you see changed tasks, or will Ansible skip everything?
- Run the playbook:
ansible-playbook deploy.yml
The PLAY RECAP shows changed=0 for both hosts. Ansible confirmed that everything is already in the desired state. This is idempotency at work again.
- Get the IP addresses of both VMs by running:
multipass list
- Open your browser and visit http:// followed by web1's IPv4 address.
- Open a second browser tab and visit http:// followed by web2's IPv4 address.
Web1 displays "Server: web1" and "Role: primary" while web2 displays "Server: web2" and "Role: replica". Two different pages, generated from one playbook and one template.
Why does this matter at scale?
Imagine 50 servers across three environments: production, staging, and development. You assign variables like environment=production or environment=staging in your inventory.
Your playbooks use those variables to set database endpoints, log levels, and feature flags. One codebase drives all environments with no copy-paste duplication.
Both pages show the same content?
Make sure each host line in inventory.ini has a different server_role value (primary for web1, replica for web2).
If you recently edited the inventory, re-run ansible-playbook deploy.yml so Ansible pushes the updated page. Then hard-refresh the browser with Cmd+Shift+R (macOS) or Ctrl+Shift+R (Windows).
help me troubleshoot identical pages on my Ansible-managed VMs
You now manage a fleet where each server knows its role and configures itself accordingly. Next up, you will take this further in the Secret Mission by deploying with professional-grade Jinja2 template files.
Secret mission
Deploy with Jinja2 Templates
Your playbook works, but the inline HTML inside deploy.yml is hard to read and impossible to maintain as pages grow. Real platform engineers separate templates from logic. In this mission, you will create a standalone Jinja2 template file and swap the copy task for the professional-grade template module.
Clean Up Your Resources
Clean Up Your Resources
Decide whether to keep your resources running, pause them to come back later, or delete them entirely.
This project runs entirely on your local Mac, so there are no ongoing cloud costs. However, the two VMs consume approximately 1GB of RAM and 10GB of disk space combined while running.
Resources you used:
- Two Multipass VMs (web1 and web2) consuming ~512MB RAM and ~5GB disk each.
- Project directory at ~/ansible-fleet containing your inventory, config, playbook, and templates.
- SSH key pair at ~/.ssh/ansible_key and ~/.ssh/ansible_key.pub.
Keep everything running
No action needed. Choose this if you are still actively experimenting with Ansible or want to try more playbooks against your fleet.
- Your VMs stay running and accessible via SSH.
- You can continue running ansible-playbook commands against them at any time.
- Homebrew, Python 3.12, Ansible, and Multipass remain installed on your Mac for future projects.
Pause - I'll come back to this later
Shut down the VMs to free up RAM while keeping your project files and disk images intact. You can resume everything later.
- Stop both VMs by running:
multipass stop web1 web2
- This frees up RAM immediately. Disk images remain so you can resume later.
- When you are ready to come back, start them again by running:
multipass start web1 web2
- After restarting, verify they are running with multipass list and confirm Ansible still connects with ansible all -m ping.
Delete - I don't want to use this again
Remove all project resources and start fresh. This deletes the VMs, project files, and SSH key permanently.
- Delete and purge both VMs by running:
multipass delete web1 web2 && multipass purge
- Confirm the VMs are gone by running:
multipass list
- You should see no instances listed, or only No instances found. in the output.
- Remove the project directory by running:
rm -rf ~/ansible-fleet
- Remove the dedicated SSH key pair by running:
rm ~/.ssh/ansible_key ~/.ssh/ansible_key.pub
- Homebrew, Python 3.12, Ansible, and Multipass remain installed on your Mac. These are general-purpose tools useful for future projects.
Nice Work!
Nice Work!
You just built a fully automated server fleet on your Mac. From a single ansible-playbook command, two Ubuntu VMs get configured identically with Nginx and a custom web page.
You've learned how to:
- Built a complete Ansible control node and launched a fleet of Ubuntu VMs managed entirely through SSH key-based authentication.
- Wrote and executed an Ansible playbook that installs Nginx, starts the service, and deploys a custom web page across multiple servers simultaneously.
- Demonstrated idempotency by running the same playbook twice and watching Ansible skip all tasks with zero unnecessary changes.
- Used Jinja2 templates to generate per-host configuration from a single source of truth (Secret Mission).
Ready to quiz yourself?