Ansible
This activity puts into practice the concepts from the Configuration Management lecture. Where the earlier scripting activity handled the same web-server setup imperatively from inside the EC2 instance, here you will reimplement it declaratively with Ansible from your control machine over SSH. By the end, nginx will be installed, running, serving your custom page, and monitored by the same cron health check, all through a playbook you can re-run safely and commit to version control.
What You Will Need
Section titled “What You Will Need”- Your AWS Academy Learner Lab session started
- Your
cs312-key.pemSSH key pair (created in your Learner Lab, already in~/.ssh/) - A Linux-style control environment with
sshavailable. macOS works directly, Windows users should use WSL rather than PowerShell for this activity, and the Arch Linux VM from the earlier activity is also a good control node if you still have it. - Ansible installed on your control node (your local machine) before class: follow the official installation guide and verify with
ansible --version ansible-lintinstalled on your control node: follow the official installation guide orbrew install ansible-linton macOS with Homebrew. Verify withansible-lint --version.
Launch Your EC2 Instance
Section titled “Launch Your EC2 Instance”You will create a fresh EC2 instance for this activity. The instance will serve as your Ansible managed node: the server that Ansible configures remotely over SSH. AWS gives every instance a user-data field: a script that cloud-init runs exactly once, on first boot, before anything else starts. You will use it to ensure the instance is ready for Ansible without logging in and manually preparing the server.
-
Open the EC2 console and start the launch wizard.
In your Learner Lab, click “AWS” to open the console. Navigate to EC2 and click “Launch instances.”
-
Set the instance name and choose an AMI.
- Name:
cs312-ansible-activity - AMI: Ubuntu Server 26.04 LTS (the default Ubuntu option)
- Architecture: 64-bit (x86)
- Name:
-
Choose instance type and key pair.
- Instance type:
t3.micro - Key pair: select
cs312-keyfrom the dropdown
- Instance type:
-
Configure the security group.
Under “Network settings,” click “Edit.” Create a new security group with two inbound rules:
Type Port Source SSH 22 My IP or Anywhere (0.0.0.0/0) HTTP 80 Anywhere (0.0.0.0/0) -
Add the user-data script.
Expand “Advanced details.” Scroll to the “User data” field at the bottom and paste:
#!/bin/bash# cloud-init runs this script once at first boot.# The standard Ansible modules used in this activity need Python 3.# Everything else on this server will be configured by Ansible.apt-get update -yapt-get install -y python3This script ensures a usable Python 3 interpreter is present. For the standard Linux modules used in this activity, Ansible transfers small Python modules to the managed node and executes them remotely. Without Python 3, modules like
ping,setup,apt,copy, andservicecannot run. -
Launch the instance and note the public IP.
Click “Launch instance.” Once the instance reaches the
Runningstate, copy the Public IPv4 address. You will use this address throughout the activity. -
Wait for cloud-init to finish before using Ansible.
Replace
<YOUR-EC2-PUBLIC-IP>with the value you just copied, then run:Terminal window ssh -i ~/.ssh/cs312-key.pem ubuntu@<YOUR-EC2-PUBLIC-IP> \'cloud-init status --wait && python3 --version'If this is your first SSH connection to the instance, type
yesto accept the host key. This command waits for first-boot setup to finish and confirms that Python 3 is installed before Ansible tries to connect. It can take a minute or two on a new instance.
Set Up the Control Node
Section titled “Set Up the Control Node”The control node is your local machine: the place where you write and run Ansible commands. Ansible connects to the EC2 instance over SSH and executes modules remotely. Nothing extra needs to be installed on the EC2 instance beyond what user-data already handled.
-
Verify your Ansible installation:
Terminal window ansible --versionYou should see
ansible [core X.X.X]and a Python version below it. If Ansible is not installed, follow the official installation guide for your platform. -
Create your working directory:
Terminal window mkdir -p ~/cs312-ansiblecd ~/cs312-ansibleAll files you create for this activity go in this directory.
Build Your Inventory
Section titled “Build Your Inventory”An inventory tells Ansible which machines to manage and how to reach them. You will create a minimal INI-format inventory file with your EC2 instance.
Create a file named hosts.ini in your working directory with the following content. Replace <YOUR-EC2-PUBLIC-IP> with your instance’s actual public IP from the EC2 console:
[managed]ec2 ansible_host=<YOUR-EC2-PUBLIC-IP> ansible_user=ubuntu
[managed:vars]ansible_ssh_private_key_file=~/.ssh/cs312-key.pemansible_ssh_common_args=-o StrictHostKeyChecking=accept-newThe [managed] block defines a group named managed with one host aliased ec2. The [managed:vars] block applies variables to every host in that group: ansible_ssh_private_key_file points to your key, and ansible_ssh_common_args tells SSH to accept the host key automatically on first connection without prompting.
Now test connectivity using the ping module:
ansible -i hosts.ini managed -m ansible.builtin.pingA successful result looks like this:
ec2 | SUCCESS => { "changed": false, "ping": "pong"}Explore the Managed Node
Section titled “Explore the Managed Node”With connectivity confirmed, you can run ad-hoc commands to inspect the server. Each command uses -m to name a module and optionally -a to pass arguments.
-
Gather system facts. The
setupmodule collects information about the managed node: OS version, memory, CPU, network interfaces, and more. Thefilterargument narrows the output:Terminal window ansible -i hosts.ini managed -m ansible.builtin.setup -a "filter=ansible_distribution*"Note the OS name and version. These values are available as variables in playbooks by default because Ansible gathers facts automatically unless you disable it.
-
Check disk usage:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "df -h /"Notice that the output begins with
"changed": trueeven thoughdfreads disk usage and changes nothing at all. In this ad-hoc form, thecommandmodule runs a shell-free command without inspecting remote state first, so Ansible reports it as changed by default. -
Create a file with your name. Replace
YOUR NAMEwith your actual name:Terminal window ansible -i hosts.ini managed -m ansible.builtin.copy \-a "content='Deployed by: YOUR NAME\n' dest=/tmp/cs312.txt"The output shows
"changed": trueand a greenchangedlabel. -
Read the file back to confirm it was created:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "cat /tmp/cs312.txt"Your name appears in the
stdoutfield. -
Run the copy command again, identical to step 3:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.copy \-a "content='Deployed by: YOUR NAME\n' dest=/tmp/cs312.txt"This time the output shows
"changed": falseand a yellowoklabel. The file already exists with exactly that content, so Ansible made no changes.
Manage Packages Ad-Hoc
Section titled “Manage Packages Ad-Hoc”The apt module manages packages on Debian-based systems. Package management requires root privileges, which Ansible requests with the --become flag.
-
Install the
treepackage:Terminal window ansible -i hosts.ini managed -m ansible.builtin.apt \-a "name=tree state=present update_cache=yes cache_valid_time=3600" --becomeThe output shows
"changed": true. -
Verify the installation:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "tree --version" -
Run the same install command again:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.apt \-a "name=tree state=present update_cache=yes cache_valid_time=3600" --becomeBecause you just refreshed the package cache and installed the package, this immediate second run should show
"changed": false. Theaptmodule foundtreealready installed at the requested state and the cache still fresh, so it did nothing. -
Remove the package:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.apt \-a "name=tree state=absent" --becomeThis demonstrates the
state=absentpattern: the same module manages both installation and removal. Tracking absent packages as code matters in real playbooks when you need to ensure a forbidden package is never present.
Your First Playbook
Section titled “Your First Playbook”Ad-hoc commands are convenient for exploration, but they are one-off operations: nothing is recorded, reviewed, or repeatable. A playbook is a YAML file that describes the desired state of your server and can be committed to version control, reviewed, and re-run safely.
Rather than writing the entire playbook at once, you will grow it in three passes. First you will manage packages and the nginx service, then add the custom page and handler, then add the cron health check. After each pass, lint the playbook and run it so you can see exactly what changed and what stayed boring.
Start with Packages and Service
Section titled “Start with Packages and Service”Begin with the smallest useful playbook. This first version only installs the packages you need and ensures nginx is running, which gives you an immediate visible result before you add templating or cron.
-
Create the first version of
site.yml:---- name: Configure web serverhosts: managedbecome: truetasks:- name: Install nginx and curlansible.builtin.apt:name:- nginx- curlstate: presentupdate_cache: truecache_valid_time: 3600- name: Ensure nginx is running and enabledansible.builtin.service:name: nginxstate: startedenabled: trueSave the file in
~/cs312-ansible/site.yml. -
Run the linter against this first version:
Terminal window ansible-lint site.ymlA clean playbook produces no output. If you see warnings, fix them before continuing. One of the conventions ansible-lint enforces is fully qualified collection names:
ansible.builtin.aptinstead of the short formapt. Every module in this playbook uses that convention, which makes the source collection unambiguous as playbooks grow to include modules from multiple collections. You can runansible-lint site.yml --fixto automatically apply some fixes. -
Preview what this version will do:
Terminal window ansible-playbook -i hosts.ini site.yml --check --diffThis preview should predict package and service changes. It should tell you nginx will be installed and fail on the service call because nginx is not yet installed on the system.
-
Run the playbook:
Terminal window ansible-playbook -i hosts.ini site.ymlOn the first run, the package task should show
changed, and the service task may also showchangedif nginx was not already started. -
Run the same playbook a second time right away:
Terminal window ansible-playbook -i hosts.ini site.ymlOn this immediate second run, both tasks should now show
ok. Look at the PLAY RECAP printed at the bottom of the output: it shows one line per host with counters forok(found the system already correct),changed(took an action),failed, andskipped. On this second run,changedshould be zero. The server is already in the declared state, so Ansible had nothing to do. A PLAY RECAP withchanged=0on a re-run is idempotency made visible. -
Verify that nginx is serving its default page:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "/usr/bin/curl -s http://localhost/"You should see HTML containing
Welcome to nginx!. That is your first proof that the playbook is doing useful work.
Add the Custom Page and Handler
Section titled “Add the Custom Page and Handler”Now that the base web server is working, replace nginx’s default page with your own content. This is also where the playbook starts to show Ansible’s handler behavior: change the page, and nginx reloads exactly once at the end of the play.
-
Replace
site.ymlwith this second version. ReplaceYour Namewith your actual name before saving:---- name: Configure web serverhosts: managedbecome: truevars:student_name: "Your Name"tasks:- name: Install nginx and curlansible.builtin.apt:name:- nginx- curlstate: presentupdate_cache: truecache_valid_time: 3600- name: Deploy custom index pageansible.builtin.copy:content: "<h1>Deployed by {{ student_name }} on {{ ansible_hostname }}</h1>\n"dest: /var/www/html/index.htmlowner: www-datagroup: www-datamode: "0644"notify: Reload nginx- name: Ensure nginx is running and enabledansible.builtin.service:name: nginxstate: startedenabled: truehandlers:- name: Reload nginxansible.builtin.service:name: nginxstate: reloaded -
Lint the updated playbook:
Terminal window ansible-lint site.yml -
Preview the file change before applying it:
Terminal window ansible-playbook -i hosts.ini site.yml --check --diffYou should see a diff for
/var/www/html/index.htmlshowing the default page being replaced with your custom heading. -
Run the updated playbook:
Terminal window ansible-playbook -i hosts.ini site.ymlThis time the page task should show
changed, and theReload nginxhandler should appear once near the end of the play. -
Run the updated playbook a second time:
Terminal window ansible-playbook -i hosts.ini site.ymlNow every task should show
ok, and the handler should not appear. Nothing about the page changed, so Ansible has no reason to reload nginx. -
Verify the deployed page:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "/usr/bin/curl -s http://localhost/"You should see:
<h1>Deployed by Your Name on ip-X-X-X-X</h1>Your name and the EC2 hostname appear in the response.
Add the Health Check
Section titled “Add the Health Check”The last pass adds the cron-based health check from the scripting activity. This final version keeps the page and service behavior you already verified, then adds one more piece of state for Ansible to manage.
-
Replace
site.ymlwith the final version:---- name: Configure web serverhosts: managedbecome: truevars:student_name: "Your Name"tasks:- name: Install nginx and curlansible.builtin.apt:name:- nginx- curlstate: presentupdate_cache: truecache_valid_time: 3600- name: Deploy custom index pageansible.builtin.copy:content: "<h1>Deployed by {{ student_name }} on {{ ansible_hostname }}</h1>\n"dest: /var/www/html/index.htmlowner: www-datagroup: www-datamode: "0644"notify: Reload nginx- name: Ensure nginx is running and enabledansible.builtin.service:name: nginxstate: startedenabled: true- name: Install nginx health-check scriptansible.builtin.copy:content: |#!/usr/bin/env bashset -euo pipefailSTATUS=$(/usr/bin/curl -s -o /dev/null -w "%{http_code}" http://localhost/)printf "[%s] nginx: %s\n" "$(date '+%Y-%m-%d %H:%M:%S')" "$STATUS" >> /tmp/nginx-health.logdest: /usr/local/bin/nginx-health.showner: rootgroup: rootmode: "0755"- name: Schedule nginx health checkansible.builtin.cron:name: nginx health checkminute: "*"user: rootjob: /usr/local/bin/nginx-health.shcron_file: nginx-healthhandlers:- name: Reload nginxansible.builtin.service:name: nginxstate: reloadedThe
cron_fileparameter writes the job to/etc/cron.d/nginx-healthas a system cron file rather than a user crontab. Thenamefield (nginx health check) is how Ansible identifies this specific entry across runs: it is written as a comment in the cron file so a re-run can find and update it rather than adding a duplicate. -
Lint the final playbook:
Terminal window ansible-lint site.yml -
Preview the final additions:
Terminal window ansible-playbook -i hosts.ini site.yml --check --diffYou should see
changedpredictions for the health-check script and the cron entry. The page task should usually remainokbecause you already applied that change in the previous pass. -
Run the final playbook:
Terminal window ansible-playbook -i hosts.ini site.ymlThe health-check script and cron task should show
changedon this first final run. -
Run the final playbook a second time right away:
Terminal window ansible-playbook -i hosts.ini site.ymlIf you rerun it immediately, every task should now show
ok. The package cache is still fresh, the page already matches, and the cron job already exists with the desired content. -
Wait for the next cron run and then check the health log:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "cat /tmp/nginx-health.log"Wait until the next minute boundary, then run the command. You should see one or more lines like:
[2026-04-22 10:25:01] nginx: 200Status
200means nginx is responding correctly. If the file is still empty, wait one more minute and run the command again.
Store a Secret with Ansible Vault
Section titled “Store a Secret with Ansible Vault”Ansible Vault encrypts values so they can live safely in version control. Right now student_name sits in plain text inside site.yml. This section moves it into an encrypted group_vars file so you can see what Vault actually stores on disk and what Ansible decrypts at run time.
-
Create the group_vars directory for the
managedgroup:Terminal window mkdir -p group_vars/managedAnsible automatically loads all YAML files inside
group_vars/<groupname>/and makes their values available to every play targeting that group. -
Write the variable as plain text, then encrypt the file. Replace
Your Namewith your actual name before running:Terminal window echo 'student_name: "Your Name"' > group_vars/managed/secrets.ymlansible-vault encrypt group_vars/managed/secrets.ymlAnsible will prompt you to set and confirm a vault password. Choose anything you will remember for the rest of this activity.
-
Inspect the encrypted file:
Terminal window cat group_vars/managed/secrets.ymlInstead of your name, you will see something like:
$ANSIBLE_VAULT;1.1;AES25662303862363832653831623366383064316135613164393836383432393437363366623763373832...This is what would be committed to Git. Anyone without the vault password sees only this ciphertext.
-
Remove the
varsblock fromsite.yml. Delete these three lines:vars:student_name: "Your Name"Ansible will now load
student_namefrom the encryptedgroup_varsfile instead of from the playbook. -
Run the playbook with the vault password:
Terminal window ansible-playbook -i hosts.ini site.yml --ask-vault-passEnter the password you set in step 2. The PLAY RECAP should show
changed=0: the deployed page has not changed becausestudent_nameresolved to the same value. Ansible decrypted the file on the control node during the run and discarded it afterward. The managed node never received the vault password or the raw variable file. -
Verify the page still shows your name:
Terminal window ansible -i hosts.ini managed -m ansible.builtin.command -a "/usr/bin/curl -s http://localhost/"The output should be identical to before:
<h1>Deployed by Your Name on ip-X-X-X-X</h1>
Going Further
Section titled “Going Further”You have built the core Ansible workflow: inventory, ad-hoc exploration to understand a system, a playbook with handlers that is safe to re-run, lint checks before every run, a dry-run preview before applying changes, and encrypted variables in version control. The natural next step is to make playbooks more flexible and reusable.
I highly recommend Jeff Geerling’s Ansible 101 YouTube series if you want to explore Ansible further.
- Roles package tasks, handlers, templates, and defaults into a reusable directory structure. Once you have written a role for nginx, you can apply it to any inventory with a single line.
- The
templatemodule and Jinja2 templating let you render configuration files differently per host. Replace the inlinecontent:in the index page task with a.j2template file to see how templates differ from static copies. - Ansible Galaxy hosts community-maintained roles. Try installing
geerlingguy.nginxand applying it, then compare its structure to the nginx task you wrote by hand.