Deploy Nginx with Ansible on EC2

Automate Nginx deployment on AWS EC2 using Ansible playbooks and roles.

Introduction

30 Second Summary

Imagine you need to set up a web server. You could SSH in and run commands manually, but what happens when you need to do it on ten servers? Or fifty? DevOps teams solve this by writing automation that declares what a server should look like and lets a tool make it happen.

In this project, you will build an Ansible automation project that deploys and configures an Nginx web server on a remote AWS EC2 instance. You will start with basic commands, build up to a full playbook, and refactor everything into a reusable role structure.

What You'll Build

A complete Ansible project that provisions and configures a live Nginx web server on a remote EC2 instance, progressing from ad-hoc commands to a production-ready role structure.

By the end of this project, you'll have:

  • A working Ansible inventory pointing to your remote EC2 server, with tested SSH connectivity.
  • A playbook that installs Nginx, deploys a templated configuration, and serves a custom web page using Jinja2 templates and handlers.
  • A refactored role structure following production best practices that can be reused across any number of servers.
  • Secret Mission: Add a second role that hardens your server with firewall rules and SSH configuration changes.

Are there any prerequisites?

You need an AWS account (free tier eligible) and VS Code installed on your machine. This project uses WSL 2 on Windows as the Ansible control node. No prior Ansible experience is required.

Set Up WSL, Ansible, and AWS CLI

To automate infrastructure with Ansible, you need a Linux control node. Ansible does not run natively on Windows.

The solution is WSL 2 (Windows Subsystem for Linux), which gives you a full Ubuntu environment inside Windows. In this step, you will install WSL 2, then set up Ansible and the AWS CLI inside that Linux environment.

In this step, get ready to:
  • Install WSL 2 with Ubuntu on your Windows machine.
  • Install Ansible and the AWS CLI inside WSL Ubuntu.
  • Configure AWS credentials and generate an SSH key pair for EC2 access.
Install WSL 2 with Ubuntu on Windows

WSL 2 runs a real Linux kernel on your Windows machine. This gives you full compatibility with Linux tools like Ansible that cannot run on Windows directly.

  • Open PowerShell as Administrator by pressing the Windows key, typing PowerShell, right-clicking Windows PowerShell, and selecting Run as administrator.
  • Install WSL by running this command:
wsl --install

What does this command do?

This single command enables the WSL feature, downloads the latest Linux kernel, sets WSL 2 as the default version, and installs Ubuntu as your Linux distribution.

  • Restart your machine when prompted.
  • After restart, an Ubuntu terminal window will open automatically. It will ask you to create a Linux username and password.
  • Enter a username and password you will remember.

Remember your Linux password

You will need this password whenever you run sudo commands inside WSL. It does not need to match your Windows password.

  • Verify WSL 2 is running by opening a new PowerShell window and running:
wsl -l -v

✔️ I see Ubuntu with VERSION 2

WSL 2 is ready. Move on to the next substep.

ⓧ I see VERSION 1 instead of 2

Your Ubuntu distribution is running under WSL 1. Convert it to WSL 2 by running:

wsl --set-version Ubuntu 2

This requires Windows 10 version 2004 (Build 19041) or higher. After the conversion completes, run wsl -l -v again to confirm VERSION 2.

ⓧ Ubuntu is not listed

WSL installed but the Ubuntu distribution did not download. Install it manually by running:

wsl --install -d Ubuntu

After installation completes, run wsl -l -v again to confirm Ubuntu appears with VERSION 2.

Install Ansible and the AWS CLI inside WSL Ubuntu

All remaining commands in this project run inside your WSL Ubuntu terminal. From here on, you are working in Linux.

  • Open your Ubuntu terminal by pressing the Windows key, typing Ubuntu, and pressing Enter.
  • Update your package list and install pipx by running these commands:
# Update system packages
sudo apt update && sudo apt upgrade -y

# Install pipx for isolated Python app installs
sudo apt install pipx -y
pipx ensurepath

Why pipx instead of pip?

Ubuntu 24.04 restricts global pip installs due to PEP 668. The pipx tool creates an isolated virtual environment for each app automatically.

The official Ansible docs recommend pipx when pip is restricted.

  • Close and reopen your Ubuntu terminal so the PATH update from pipx ensurepath takes effect.
  • Install Ansible by running:
# Install Ansible with all community collections included
pipx install --include-deps ansible

Why --include-deps?

The --include-deps flag installs the full ansible package (not just ansible-core). This includes 85+ community collections like ansible.posix that you will need later for the firewall module in the Secret Mission.

  • Verify Ansible is installed by running:
ansible --version

You should see version information including ansible-core and the config file path.

  • Install the AWS CLI v2 by running these commands:
# Download the AWS CLI v2 installer
curl "https://awscli.amazonaws.com/awscli-exe-linux-x86_64.zip" -o "awscliv2.zip"

# Unzip and run the installer
unzip awscliv2.zip
sudo ./aws/install

What do these commands do?

  • curl downloads the official AWS CLI v2 bundled installer for Linux.
  • unzip extracts the installer into an aws directory.
  • sudo ./aws/install installs the CLI to /usr/local/aws-cli and creates a symlink in /usr/local/bin.
  • Verify the AWS CLI is installed by running:
aws --version

You should see output starting with aws-cli/2. followed by Python and OS information.

Command not found after installing?

If ansible --version or aws --version returns "command not found", close your terminal and reopen it. The PATH changes need a fresh shell session to take effect.

Help me troubleshoot my installation

Configure AWS CLI with your credentials

Ansible uses the AWS CLI credentials to launch EC2 instances. You also need an SSH key pair so Ansible can connect to the remote server over SSH.

  • Run the AWS configuration wizard:
aws configure

What does this command do?

This launches an interactive wizard that stores your AWS credentials and preferences in ~/.aws/credentials and ~/.aws/config. These files are read by both the AWS CLI and Ansible when they need to authenticate with AWS.

  • When prompted, enter each value and press Enter:
  • AWS Access Key ID: paste your Access Key ID.
  • AWS Secret Access Key: paste your Secret Access Key.
  • Default region name: enter us-east-1.
  • Default output format: enter json.

Where do I find my Access Keys?

In the AWS Console, click your username in the top right, then select Security credentials.

Scroll to Access keys and click Create access key. Copy both the Access Key ID and Secret Access Key immediately. You will not be able to see the Secret Access Key again after closing the dialog.

  • Generate an SSH key pair for EC2 access by running:
# Generate an ed25519 SSH key pair with no passphrase
ssh-keygen -t ed25519 -f ~/.ssh/ansible-ec2 -N ""

What does this command do?

  • -t ed25519 specifies the key type (modern and secure).
  • -f ~/.ssh/ansible-ec2 sets the file path for the private key. The public key is saved as ~/.ssh/ansible-ec2.pub.
  • -N "" sets an empty passphrase so Ansible can use the key without prompting.
  • Confirm the key files were created by running:
ls ~/.ssh/ansible-ec2*

You should see ansible-ec2 and ansible-ec2.pub listed in the output.

  • Import the public key to AWS EC2 by running:
# Import your public key to AWS as a named key pair
aws ec2 import-key-pair --key-name "ansible-ec2" --public-key-material fileb://~/.ssh/ansible-ec2.pub

What does this command do?

This uploads your public key to AWS and registers it under the name ansible-ec2. When you launch an EC2 instance with this key pair, AWS places the public key on the server.

Your private key (which never leaves your machine) proves your identity when connecting via SSH.

You should see a JSON response with KeyName and KeyFingerprint fields confirming the import succeeded.

Getting a permissions or authentication error?

If you see "Unable to locate credentials", run aws configure again and double-check your Access Key ID and Secret Access Key are pasted correctly.

If you see "InvalidKeyPair.Duplicate", the key name ansible-ec2 already exists in your AWS account. Delete the old one first with:

aws ec2 delete-key-pair --key-name "ansible-ec2"

Then re-run the import command.

Help me fix my AWS CLI credentials or key import

Your Linux control node is fully equipped. Next up, you will launch an EC2 instance and build your Ansible inventory to connect to it.

Launch an EC2 Instance and Build Your Inventory

With your local tools ready, it is time to create the remote server that Ansible will manage.

In this step, you will launch an EC2 instance running Amazon Linux 2023, set up a security group to control network access, and then build an Ansible inventory file that tells Ansible where to find your server.

In this step, get ready to:
  • Launch a free-tier EC2 instance with a security group allowing SSH and HTTP.
  • Create an Ansible inventory file pointing to your new server.
  • Verify Ansible can reach your instance with a ping test.
Launch a free-tier EC2 instance from WSL

Before Ansible can configure a server, you need a server to configure. You will use the AWS CLI to create a security group, open the necessary ports, and launch an instance.

  • Create a security group named ansible-sg by running this command:
aws ec2 create-security-group \
  --group-name ansible-sg \
  --description "Security group for Ansible project"

What does this command do?

A security group acts as a virtual firewall for your EC2 instance. This command creates one named ansible-sg in your default VPC.

The output includes a GroupId value (like sg-0abc123def456). Copy this value because you will need it for the next commands.

  • Save the GroupId from the output. You will use it in every remaining command in this substep.
  • Allow inbound SSH traffic (port 22) by running this command:
aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID from above"]] \
  --protocol tcp \
  --port 22 \
  --cidr 0.0.0.0/0
  • Allow inbound HTTP traffic (port 80) by running this command:
aws ec2 authorize-security-group-ingress \
  --group-id [[SG_ID="your security group ID from above"]] \
  --protocol tcp \
  --port 80 \
  --cidr 0.0.0.0/0

Why open both ports?

Port 22 (SSH) is how Ansible connects to your server to run commands. Port 80 (HTTP) is how you will verify Nginx is serving web pages later in the project.

  • Launch your EC2 instance by running this command:
aws ec2 run-instances \
  --image-id resolve:ssm:/aws/service/ami-amazon-linux-latest/al2023-ami-kernel-default-x86_64 \
  --instance-type t3.micro \
  --key-name ansible-ec2 \
  --security-group-ids [[SG_ID="your security group ID from above"]] \
  --count 1

What does this command do?

The resolve:ssm: prefix tells AWS to look up the latest Amazon Linux 2023 AMI automatically using Systems Manager. This means you always get the most up-to-date image without hardcoding an AMI ID.

The t3.micro instance type is eligible for the AWS free tier. The --key-name ansible-ec2 references the SSH key pair you imported in Step 1.

  • The output is a large JSON block. Find the InstanceId value and note it down.
  • Wait about 30 seconds for the instance to start, then get its public IP by running:
aws ec2 describe-instances \
  --instance-ids [[INSTANCE_ID="your instance ID from above"]] \
  --query "Reservations[0].Instances[0].PublicIpAddress" \
  --output text
  • Copy the public IP address from the output. You will use it in the next substep.

No public IP returned?

If the output shows None, the instance may still be starting. Wait another 30 seconds and run the describe-instances command again.

If it still shows None after a minute, your default VPC subnet may not auto-assign public IPs. Re-launch with --associate-public-ip-address added to the run-instances command.

help me troubleshoot why my EC2 instance has no public IP address

Create your Ansible inventory file

An inventory file tells Ansible which servers to manage and how to group them. You will create a simple INI-format inventory that points to your new EC2 instance.

  • Create a project directory and move into it by running these commands:
mkdir ~/ansible-project
cd ~/ansible-project
  • Create inventory.ini by running this command:
cat > inventory.ini << 'EOF'
[webservers]
web1 ansible_host=[[EC2_IP="your EC2 public IP address"]]
EOF

What is an Ansible inventory?

The inventory file maps a friendly name (web1) to a real server address. The [webservers] header creates a group so you can target all servers in the group by name.

The ansible_host variable tells Ansible the actual IP or hostname to connect to for this entry.

  • Verify the file was created correctly by running:
cat inventory.ini
  • You should see your [webservers] group with web1 and your EC2 IP address listed.

File is empty or missing?

Make sure you ran the full heredoc command exactly as shown, including the closing EOF on its own line with no spaces before it.

help me create my Ansible inventory file in WSL

✔️ Awesome, I've got everything!

Great. Double check you have saved inventory.ini in your ~/ansible-project directory.

ⓧ I'd like to double check the full code

Here is what inventory.ini should look like (with your own IP address substituted):

[webservers]
web1 ansible_host=your-ec2-public-ip-here
Test connectivity with the ping module

Ansible's ping module tests whether Ansible can connect to and execute Python on the remote host. A successful response proves your SSH key, network access, and inventory are all configured correctly.

  • Set the correct permissions on your SSH private key by running:
chmod 600 ~/.ssh/ansible-ec2

Why chmod 600?

SSH refuses to use a private key if other users on the system can read it. The 600 permission means only you (the owner) can read or write the file.

  • Test the connection by running this command:
ansible webservers -i inventory.ini -m ping \
  -u ec2-user \
  --private-key ~/.ssh/ansible-ec2 \
  --ssh-extra-args='-o StrictHostKeyChecking=no'

What does this command do?

  • webservers targets the group you defined in inventory.ini.
  • -i inventory.ini points Ansible to your inventory file.
  • -m ping runs the ping module, which tests SSH connectivity and Python availability.
  • -u ec2-user specifies the SSH username for Amazon Linux.
  • --private-key ~/.ssh/ansible-ec2 tells Ansible which SSH key to use.
  • --ssh-extra-args disables host key checking for this first connection so SSH does not block on the fingerprint prompt.
  • You should see a green SUCCESS response like this:
web1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

The "ping": "pong" response confirms Ansible can reach your EC2 instance, authenticate via SSH, and execute commands.

Connection timed out or permission denied?

If you see a timeout, check that your security group allows SSH (port 22) from 0.0.0.0/0. Verify the IP in your inventory matches the actual public IP of your instance.

If you see "Permission denied", confirm your private key path is correct and the permissions are 600. Also verify the username is ec2-user (not ubuntu or root).

help me fix my Ansible ping connection failure to EC2

Your EC2 instance is live and Ansible can reach it. Next up, you will run ad-hoc commands to control your server without writing a single playbook file.

Run Ad-Hoc Commands

In Step 2, you launched an EC2 instance and confirmed Ansible can reach it with a successful ping. Now you'll start putting Ansible to work by running real commands on your remote server.

Ad-hoc commands are one-off tasks you run directly from the terminal without writing a playbook file. They're perfect for quick checks, troubleshooting, and proving that Ansible's module system works before investing time in automation scripts.

In this step, get ready to:
  • Gather system information from your remote server using multiple Ansible modules.
  • Install Nginx using an ad-hoc command and observe idempotency.
  • Start and enable the Nginx service so it serves a web page.
Execute ad-hoc commands to gather system information

Ansible uses modules to perform specific tasks on remote servers. You select a module with the -m flag and pass arguments with -a.

  • Check your server's hostname by running this command:
ansible webservers -i inventory.ini -m command -a "hostname"

You should see a response containing your EC2 instance's hostname along with a green "SUCCESS" status.

  • Check how long the server has been running by running:
ansible webservers -i inventory.ini -m command -a "uptime"

You should see the server's uptime, number of users, and load averages.

What is the command module?

The command module executes a command directly on the target server without processing it through a shell. This means shell features like pipes (|) and redirects (>) won't work with it.

When you need those shell features, you switch to the shell module instead.

  • Check available disk space using the shell module by running:
ansible webservers -i inventory.ini -m shell -a "df -h"

You should see a table showing filesystem sizes, used space, and mount points.

  • Gather detailed facts about your remote system using the setup module by running:
ansible webservers -i inventory.ini -m setup

This outputs a large JSON object with everything Ansible discovered about your server, including OS name, IP addresses, and memory.

What is the setup module?

The setup module gathers "facts" about remote hosts. Facts are structured variables like ansible_hostname, ansible_distribution, and ansible_default_ipv4.address.

Playbooks automatically run this module before executing tasks so they can use these facts in templates and conditionals. You'll use several of these facts later in this project.

Getting a connection error?

If any of these commands fail with "UNREACHABLE", make sure your EC2 instance is still running and that your inventory.ini has the correct public IP. Instance IPs can change if you stop and restart the instance.

help me troubleshoot Ansible ad-hoc command connection errors to my EC2 instance

Install Nginx with an ad-hoc command

Now that you've confirmed Ansible can run commands on your server, you'll use it to install software. This is where Ansible starts proving its value over manual SSH sessions.

  • Install Nginx on your EC2 instance by running:
ansible webservers -i inventory.ini -m ansible.builtin.dnf -a "name=nginx state=present" --become

What does this command do?

The ansible.builtin.dnf module manages packages on Red Hat-based systems like Amazon Linux 2023. The state=present argument tells Ansible to install the package if it's not already there.

The --become flag elevates privileges to root, which is required for installing packages. Without it, the command would fail with a permission error.

You should see a "CHANGED" status in yellow, confirming Nginx was installed.

  • Run the exact same command again to observe idempotency:
ansible webservers -i inventory.ini -m ansible.builtin.dnf -a "name=nginx state=present" --become

This time you should see a green "SUCCESS" status with "changed": false in the output.

What is idempotency?

Idempotency means running the same operation multiple times produces the same result. Ansible checked whether Nginx was already installed and reported "ok" instead of reinstalling it.

This is one of Ansible's most powerful features. You can safely re-run automation without worrying about breaking things or creating duplicates.

Seeing a permission denied error?

Make sure you included --become at the end of the command. Package installation requires root privileges on the remote server.

help me fix permission errors when running Ansible ad-hoc commands

Start and enable Nginx

Installing Nginx doesn't automatically start it. You need to tell the service to begin running and to launch automatically on boot.

  • Start and enable the Nginx service by running:
ansible webservers -i inventory.ini -m ansible.builtin.service -a "name=nginx state=started enabled=true" --become

What does this command do?

The ansible.builtin.service module manages system services. The state=started argument starts Nginx immediately.

The enabled=true argument configures Nginx to start automatically whenever the server reboots.

You should see a "CHANGED" status confirming the service was started.

  • Open your web browser.
  • Type your EC2 instance's public IP address into the URL bar (using http://, not https://) and press Enter.

You should see the default Nginx welcome page. This confirms that Nginx is installed, running, and serving web traffic on port 80.

Not seeing the Nginx page?

Verify that your security group allows inbound traffic on port 80. If you skipped that rule when creating the security group in Step 2, add it now through the AWS console.

Also make sure you're using http:// in the URL, not https://. Nginx is serving plain HTTP at this stage.

help me troubleshoot why I cannot see the Nginx page in my browser

You just managed a remote server entirely through Ansible ad-hoc commands without manually SSH-ing in. Next up, you'll write a playbook that combines all of these tasks into a single, repeatable file with templates and handlers.

Write Your First Playbook with Handlers and Templates

Ad-hoc commands work for one-off tasks, but they fall apart when you need to orchestrate multiple steps in sequence. A playbook lets you define your desired server state in a single YAML file that Ansible executes top to bottom.

In this step, you will write a playbook that installs Nginx, deploys a custom configuration using Jinja2 templates, and triggers a restart only when configuration changes. You will also create an ansible.cfg file so you never have to pass the same flags on every command again.

In this step, get ready to:
  • Create an Ansible configuration file and a complete playbook with variables and tasks.
  • Write Jinja2 templates that dynamically generate Nginx configuration and a custom web page.
  • Add handlers that restart Nginx only when templates change, and observe idempotency in action.
Create ansible.cfg and your playbook

Every time you ran an ad-hoc command in the last step, you had to specify the inventory file, remote user, and private key. An ansible.cfg file sets these defaults so Ansible picks them up automatically.

  • In your WSL terminal, make sure you are inside your project folder (the same directory where inventory.ini lives).
  • Create ansible.cfg by running:
cat > ansible.cfg << 'EOF'
[defaults]
inventory = inventory.ini
remote_user = ec2-user
private_key_file = ~/.ssh/ansible-ec2
host_key_checking = False
EOF

What does this configuration do?

  • inventory tells Ansible where to find your host list, so you skip -i inventory.ini on every command.
  • remote_user sets the SSH username for all connections to ec2-user (the default on Amazon Linux 2023).
  • private_key_file points to the SSH key you generated in Step 1.
  • host_key_checking = False skips the SSH fingerprint prompt, which is fine for a lab environment.
  • Confirm the file was created by running:
cat ansible.cfg

You should see the four lines of configuration printed to your terminal.

File looks empty or missing?

  • Make sure you copied the entire cat > ansible.cfg << 'EOF' block including the closing EOF on its own line.
  • Run pwd to confirm you are in the same directory as inventory.ini.

Help me troubleshoot this issue

Now you will create the playbook itself. A playbook is a YAML file containing one or more "plays" that map a group of hosts to a set of tasks.

  • Create playbook.yml by running:
cat > playbook.yml << 'EOF'
---
- name: Configure Nginx web server
  hosts: webservers
  become: true
  vars:
    site_title: "My Ansible Project"
    nginx_port: 80
    server_name: "_"

  tasks:
    - name: Install Nginx
      ansible.builtin.dnf:
        name: nginx
        state: present

    - name: Deploy Nginx configuration
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/default.conf
        owner: root
        group: root
        mode: "0644"

    - name: Deploy custom index page
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /usr/share/nginx/html/index.html
        owner: root
        group: root
        mode: "0644"

    - name: Ensure Nginx is started and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
EOF

What does this playbook do?

  • hosts: webservers targets every server in your [webservers] inventory group.
  • become: true elevates to root so Ansible can install packages and write to system directories.
  • The vars section defines variables that your Jinja2 templates will use.
  • Each task uses a fully qualified module name (like ansible.builtin.dnf) and has a human-readable name that appears in the playbook output.
  • The template tasks reference .j2 files that you will create next.
  • Confirm both files exist by running:
ls ansible.cfg playbook.yml

You should see both file names listed in the output.

Seeing 'No such file or directory'?

  • Confirm you are in the correct directory with pwd.
  • If the heredoc command failed silently, open the file in nano playbook.yml and paste the YAML content manually.

Help me create this file

✔️ Awesome, I've got everything!

Great. Make sure you have saved ansible.cfg and playbook.yml in the same directory as your inventory.ini.

ⓧ I'd like to double check ansible.cfg

Your ansible.cfg should look exactly like this:

[defaults]
inventory = inventory.ini
remote_user = ec2-user
private_key_file = ~/.ssh/ansible-ec2
host_key_checking = False
Create Jinja2 templates for Nginx

Your playbook references two template files that do not exist yet. Jinja2 templates look like normal config or HTML files, but they contain {{ variable }} placeholders that Ansible fills in at runtime.

  • Create the templates directory by running:
mkdir templates
  • Create the Nginx configuration template by running:
cat > templates/nginx.conf.j2 << 'EOF'
server {
    listen {{ nginx_port }};
    server_name {{ server_name }};

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
EOF

What does this template do?

When Ansible processes this template, it replaces {{ nginx_port }} with 80 and {{ server_name }} with _ (the wildcard server name in Nginx).

The rendered file is placed at /etc/nginx/conf.d/default.conf on the remote server, overriding the default Nginx site.

  • Create the HTML page template by running:
cat > templates/index.html.j2 << 'EOF'
<!DOCTYPE html>
<html>
<head>
    <title>{{ site_title }}</title>
</head>
<body>
    <h1>{{ site_title }}</h1>
    <p>Deployed by Ansible on host: {{ ansible_hostname }}</p>
    <p>Operating System: {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
    <p>Server IP: {{ ansible_default_ipv4.address }}</p>
</body>
</html>
EOF

Where do these variables come from?

The site_title variable comes from the vars section you defined in your playbook.

The ansible_hostname, ansible_distribution, ansible_distribution_version, and ansible_default_ipv4.address variables are facts that Ansible automatically gathers from the remote server at the start of every play.

  • Confirm both templates exist by running:
ls templates/

You should see index.html.j2 and nginx.conf.j2 listed.

Templates directory empty?

  • Make sure you ran mkdir templates before creating the files inside it.
  • Check that the EOF closing line has no leading spaces. Heredoc syntax requires EOF on a line by itself.

Help me fix my templates

✔️ Awesome, I've got everything!

Your templates are ready. Next you will wire up the handler that makes Nginx reload when its configuration changes.

ⓧ I'd like to double check the templates

Your templates/nginx.conf.j2 should look like this:

server {
    listen {{ nginx_port }};
    server_name {{ server_name }};

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}

Your templates/index.html.j2 should look like this:

<!DOCTYPE html>
<html>
<head>
    <title>{{ site_title }}</title>
</head>
<body>
    <h1>{{ site_title }}</h1>
    <p>Deployed by Ansible on host: {{ ansible_hostname }}</p>
    <p>Operating System: {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
    <p>Server IP: {{ ansible_default_ipv4.address }}</p>
</body>
</html>
Add handlers and run the playbook

A handler is a special task that only runs when notified by another task. This is perfect for service restarts. You only want to restart Nginx when its configuration actually changes, not on every playbook run.

  • In playbook.yml, find the "Deploy Nginx configuration" task. Locate this line:
        mode: "0644"
  • Add notify: Restart Nginx directly below it, so the task now ends like this:
        mode: "0644"
      notify: Restart Nginx
  • At the very bottom of playbook.yml, after the last task, add this handlers section:
  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted

How do handlers work?

The notify: Restart Nginx line on the template task tells Ansible to queue the "Restart Nginx" handler whenever that task reports a change.

Handlers run once at the end of all tasks, even if notified multiple times. If nothing changes (the template is already identical), the handler is never triggered.

✔️ Awesome, I've got everything!

Make sure you have saved playbook.yml with both the notify line and the handlers section.

ⓧ I'd like to double check the full playbook

Your complete playbook.yml should look exactly like this:

---
- name: Configure Nginx web server
  hosts: webservers
  become: true
  vars:
    site_title: "My Ansible Project"
    nginx_port: 80
    server_name: "_"

  tasks:
    - name: Install Nginx
      ansible.builtin.dnf:
        name: nginx
        state: present

    - name: Deploy Nginx configuration
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/conf.d/default.conf
        owner: root
        group: root
        mode: "0644"
      notify: Restart Nginx

    - name: Deploy custom index page
      ansible.builtin.template:
        src: templates/index.html.j2
        dest: /usr/share/nginx/html/index.html
        owner: root
        group: root
        mode: "0644"

    - name: Ensure Nginx is started and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
  • Run your playbook by executing:
ansible-playbook playbook.yml

You should see each task execute with a status of ok or changed. The "Deploy Nginx configuration" and "Deploy custom index page" tasks should show changed because the templates are being deployed for the first time.

What does the play recap tell you?

At the bottom of the output, Ansible prints a recap line showing ok, changed, unreachable, and failed counts. A successful first run typically shows several changed tasks because Ansible is applying your desired state for the first time.

  • Open your browser and visit your EC2 instance's public IP address (the same one from your inventory).

You should see a page with the heading "My Ansible Project" along with details about the hostname, operating system, and server IP.

Seeing the default Nginx page instead?

  • Check that templates/nginx.conf.j2 exists and is not empty. The template task will silently produce a blank file if the source is missing content.
  • Make sure the dest path in your playbook is /etc/nginx/conf.d/default.conf. A typo here means Nginx loads its built-in config instead.
  • Verify the handler ran by checking for "RUNNING HANDLER" in the playbook output.

Help me troubleshoot my Nginx page

  • Run the playbook a second time to observe idempotency:
ansible-playbook playbook.yml

This time, every task should report ok instead of changed. The handler does not run because no templates changed.

Why does idempotency matter?

Idempotency means you can run the same playbook ten times in a row and the server ends up in the same state every time. Nothing is duplicated or overwritten unnecessarily.

This is what makes Ansible safe to re-run after a failure or when onboarding a new team member. Everyone runs the same playbook and gets an identical server.

  • Now change a variable to see the handler trigger. Run:
sed -i 's/My Ansible Project/Hello from Ansible/' playbook.yml
  • Run the playbook again:
ansible-playbook playbook.yml

This time, the "Deploy custom index page" task shows changed because the rendered template differs from what is on the server. The "Deploy Nginx configuration" task stays ok because nginx.conf.j2 did not use site_title.

  • Refresh your browser to see the updated heading.

Handlers only fire on change

Because the Nginx configuration template did not change, the "Restart Nginx" handler was not triggered. Handlers are tied to specific tasks, not to the playbook as a whole.

  • Reset the variable back to the original value by running:
sed -i 's/Hello from Ansible/My Ansible Project/' playbook.yml
  • Run the playbook one final time to restore the original page:
ansible-playbook playbook.yml

Your playbook is working and you have seen idempotency in action. Next up, you will refactor this flat playbook into a reusable role structure that scales to any number of servers.

Refactor into a Professional Role Structure

Your flat playbook works, but it has a scalability problem. Every task, variable, and handler lives in one file. If you needed to apply this configuration to a second project or share it with a teammate, you would have to copy-paste and manually edit everything.

Ansible solves this with roles. A role is a self-contained unit of automation with its own tasks, handlers, templates, and default variables. In this step, you will refactor your flat playbook into a professional role structure that can be reused across any number of servers.

In this step, get ready to:
  • Create the nginx role directory structure.
  • Build a top-level playbook that references the role.
  • Test that the role handles variable overrides without modifying any role files.
Create the role directory structure

Ansible Galaxy provides a scaffolding command that creates the standard directory layout every role needs. This saves you from manually creating folders and files.

  • Scaffold the nginx role by running this command:
ansible-galaxy init roles/nginx
  • Confirm the role structure was created by running:
ls roles/nginx

You should see folders including defaults/, handlers/, tasks/, templates/, and several others.

What did ansible-galaxy create?

The command created a full role skeleton with subdirectories for every piece of a role: tasks, handlers, templates, defaults, vars, files, meta, and tests. You only need four of these for this project: tasks/, handlers/, templates/, and defaults/.

The extra folders are fine to leave in place. They do not affect anything unless you put files in them.

Now you will populate the role with your existing playbook content. The key difference is that tasks in a role reference templates without a path prefix. Ansible automatically looks in the role's own templates/ directory.

  • Open roles/nginx/tasks/main.yml in VS Code and replace its contents with the following:
---
- name: Install Nginx
  ansible.builtin.dnf:
    name: nginx
    state: present

- name: Deploy Nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/conf.d/default.conf
    owner: root
    group: root
    mode: "0644"
  notify: Restart Nginx

- name: Deploy custom index page
  ansible.builtin.template:
    src: index.html.j2
    dest: /usr/share/nginx/html/index.html
    owner: root
    group: root
    mode: "0644"

- name: Ensure Nginx is started and enabled
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

What changed from the flat playbook?

The tasks are identical to your playbook.yml tasks, with one difference. The src: values for templates now say nginx.conf.j2 and index.html.j2 instead of templates/nginx.conf.j2 and templates/index.html.j2.

Ansible automatically resolves template paths relative to the role's templates/ folder. You do not need to specify the full path.

  • Save roles/nginx/tasks/main.yml.
  • Open roles/nginx/handlers/main.yml and replace its contents with:
---
- name: Restart Nginx
  ansible.builtin.service:
    name: nginx
    state: restarted

What does this handler do?

This is the same handler from your flat playbook. When the Nginx configuration template changes, the notify: Restart Nginx directive in the tasks file triggers this handler to restart the service with the updated configuration.

  • Save roles/nginx/handlers/main.yml.
  • Open roles/nginx/defaults/main.yml and replace its contents with:
---
site_title: "My Ansible Project"
nginx_port: 80
server_name: "_"

Why defaults instead of vars?

Variables in defaults/ have the lowest precedence. This means anyone using this role can override them easily from a playbook, inventory, or the command line. Variables in vars/ have higher precedence and are harder to override.

Putting configuration values in defaults makes your role flexible and reusable across different environments.

  • Save roles/nginx/defaults/main.yml.
  • Copy your existing templates into the role's templates directory by running these commands:
cp templates/nginx.conf.j2 roles/nginx/templates/
cp templates/index.html.j2 roles/nginx/templates/
  • Confirm the templates were copied by running:
ls roles/nginx/templates/

You should see index.html.j2 and nginx.conf.j2 listed.

Templates directory empty?

Make sure you ran the cp commands from your project root directory (the folder containing your templates/ folder and playbook.yml). If you are in the wrong directory, the source files will not be found.

help me figure out why my template files did not copy into the role

Create site.yml and run the role-based playbook

With the role populated, you need a new top-level playbook that tells Ansible which roles to apply to which hosts. This replaces the task-based approach in your flat playbook.

  • Create a new file called site.yml in your project root and add the following content:
---
- name: Configure web infrastructure
  hosts: webservers
  become: true
  roles:
    - nginx

What does this playbook do?

Instead of listing individual tasks, this playbook uses the roles: keyword to reference the nginx role by name. Ansible looks for the role in the roles/ directory, finds roles/nginx/, and automatically loads its tasks, handlers, defaults, and templates.

One line replaces your entire flat playbook. Adding another role later is just another list item.

  • Save site.yml.
  • Run the role-based playbook by running this command:
ansible-playbook site.yml

You should see each task report ok (not changed) because your server is already configured from the flat playbook. This confirms the role produces an identical result.

Why ok instead of changed?

This is idempotency in action again. Ansible checks the current state of each resource and only makes changes when the desired state differs. Since your server already has Nginx installed and configured, every task reports ok with no modifications.

Seeing template not found errors?

Verify that your templates are in roles/nginx/templates/ (not roles/nginx/templates/templates/). Also check that the src: values in roles/nginx/tasks/main.yml are just the filename (nginx.conf.j2) without any directory prefix.

help me fix a template not found error in my Ansible role

Test reusability with variable overrides

The real power of roles is configurability. Because your variables live in defaults/main.yml, anyone can override them without editing the role itself. You will prove this by changing the page title from the command line.

  • Override the site_title variable by running this command:
ansible-playbook site.yml --extra-vars "site_title=Production"

What does --extra-vars do?

The --extra-vars flag passes variables at the highest precedence level. This overrides the site_title value from defaults/main.yml without modifying any files. The same role works with different configurations for different environments.

  • Visit your EC2 public IP in a browser.

The page title should now say Production instead of My Ansible Project. This proves the role accepts external configuration without any internal changes.

Page still shows the old title?

Check that the ansible-playbook output showed changed on the "Deploy custom index page" task. If it shows ok, the template did not re-render. Make sure you typed the --extra-vars flag with the exact variable name site_title (not sitetitle or title).

help me figure out why my extra-vars override is not changing the page

✔️ Awesome, I've got everything!

Great. Double check that you have saved all role files and site.yml.

ⓧ I'd like to double check the full code

Here is the complete code for each file in the role structure:

---
- name: Install Nginx
  ansible.builtin.dnf:
    name: nginx
    state: present

- name: Deploy Nginx configuration
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/conf.d/default.conf
    owner: root
    group: root
    mode: "0644"
  notify: Restart Nginx

- name: Deploy custom index page
  ansible.builtin.template:
    src: index.html.j2
    dest: /usr/share/nginx/html/index.html
    owner: root
    group: root
    mode: "0644"

- name: Ensure Nginx is started and enabled
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
---
- name: Restart Nginx
  ansible.builtin.service:
    name: nginx
    state: restarted
---
site_title: "My Ansible Project"
nginx_port: 80
server_name: "_"
server {
    listen {{ nginx_port }};
    server_name {{ server_name }};

    root /usr/share/nginx/html;
    index index.html;

    location / {
        try_files $uri $uri/ =404;
    }
}
<!DOCTYPE html>
<html>
<head>
    <title>{{ site_title }}</title>
</head>
<body>
    <h1>{{ site_title }}</h1>
    <p>Deployed by Ansible on host: {{ ansible_hostname }}</p>
    <p>Operating System: {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
    <p>Server IP: {{ ansible_default_ipv4.address }}</p>
</body>
</html>
---
- name: Configure web infrastructure
  hosts: webservers
  become: true
  roles:
    - nginx

You have just transformed a flat playbook into a production-ready role. Your nginx role is now a self-contained, reusable automation unit that any team member can drop into their own playbooks. This is exactly how DevOps teams manage infrastructure at scale.

Secret mission

Harden Your Server

Your Nginx server is live and serving pages. But a production server needs more than just a web application running. In this bonus challenge, you will write a second Ansible role from scratch that locks down your EC2 instance with firewall rules and SSH hardening, using loops and conditionals.

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. Your EC2 instance consumes AWS Free Tier hours while running, even when idle.

Cost warning

Your EC2 instance uses Free Tier compute hours as long as it is running. If you leave it running and exhaust your 750 free hours for the month, you will start incurring charges. Stop or terminate the instance when you are not actively using it.

Resources you used:

  • 1 EC2 instance (running, consumes Free Tier hours while active).
  • 1 Security Group (allows SSH and HTTP inbound traffic).
  • 1 imported Key Pair (ansible-ec2).
  • Local Ansible project files in WSL (ansible.cfg, inventory.ini, playbook.yml, site.yml, templates/, roles/).
  • SSH key pair on your local machine (~/.ssh/ansible-ec2 and ~/.ssh/ansible-ec2.pub).

Keep everything running

No action needed. Choose this if you are still actively building or plan to extend the project immediately.

  • Your EC2 instance remains running and consumes Free Tier compute hours even when idle.
  • Monitor your usage in the AWS Billing dashboard to avoid unexpected charges.
  • Ansible, AWS CLI, and your local SSH keys remain installed in WSL.

Pause - I'll come back to this later

Stop the instance to save compute hours while keeping your files and configuration intact. Stopped instances do not consume compute hours, but EBS storage remains (Free Tier includes 30 GB).

  • Stop your EC2 instance by running this command in your WSL terminal:
aws ec2 stop-instances --instance-ids [[INSTANCE_ID="your EC2 instance ID"]]
  • When you are ready to resume, start the instance again. Note that the public IP may change after a stop/start cycle. Update your inventory.ini with the new IP before running any playbooks.

Delete - I don't want to use this again

Remove all project resources to ensure zero ongoing charges. Follow each step below in order.

  • Terminate your EC2 instance by running:
aws ec2 terminate-instances --instance-ids [[INSTANCE_ID="your EC2 instance ID"]]
  • Wait a minute for the instance to reach the terminated state, then delete the security group:
aws ec2 delete-security-group --group-id [[SG_ID="your security group ID"]]
  • Delete the imported key pair from AWS:
aws ec2 delete-key-pair --key-name ansible-ec2
  • Delete your local SSH key files:
rm ~/.ssh/ansible-ec2 ~/.ssh/ansible-ec2.pub
  • Delete your local project files. Navigate to the parent directory of your project folder, then remove the files:
rm -rf ansible.cfg inventory.ini playbook.yml site.yml templates/ roles/

How do I find my instance ID and security group ID?

Run aws ec2 describe-instances in your WSL terminal to see your instances along with their instance IDs and associated security group IDs.

Nice Work!

Nice Work!

You just built a complete Ansible automation project that deploys and configures Nginx on a remote AWS EC2 instance. From your first ad-hoc command to a production-ready role structure, you now know how DevOps engineers manage infrastructure at scale.

You learned how to:

  • Launch a remote EC2 instance and connect to it with SSH key authentication from your WSL 2 control node running Ansible.
  • Write a complete playbook with variables, Jinja2 templates, and handlers that installs and configures Nginx on a remote server.
  • Refactor a flat playbook into a professional role structure that can be reused across any number of servers with different configurations.
  • Secret Mission: Write a hardening role with loops, conditionals, and multi-role orchestration to secure your server with firewall rules and SSH configuration.

Ready to quiz yourself?