Skip to content

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.

  • Your AWS Academy Learner Lab session started
  • Your cs312-key.pem SSH key pair (created in your Learner Lab, already in ~/.ssh/)
  • A Linux-style control environment with ssh available. 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-lint installed on your control node: follow the official installation guide or brew install ansible-lint on macOS with Homebrew. Verify with ansible-lint --version.

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.

  1. 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.”

  2. 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)
  3. Choose instance type and key pair.

    • Instance type: t3.micro
    • Key pair: select cs312-key from the dropdown
  4. Configure the security group.

    Under “Network settings,” click “Edit.” Create a new security group with two inbound rules:

    TypePortSource
    SSH22My IP or Anywhere (0.0.0.0/0)
    HTTP80Anywhere (0.0.0.0/0)
  5. 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 -y
    apt-get install -y python3

    This 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, and service cannot run.

  6. Launch the instance and note the public IP.

    Click “Launch instance.” Once the instance reaches the Running state, copy the Public IPv4 address. You will use this address throughout the activity.

  7. 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 yes to 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.


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.

  1. Verify your Ansible installation:

    Terminal window
    ansible --version

    You 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.

  2. Create your working directory:

    Terminal window
    mkdir -p ~/cs312-ansible
    cd ~/cs312-ansible

    All files you create for this activity go in this directory.


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.pem
ansible_ssh_common_args=-o StrictHostKeyChecking=accept-new

The [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:

Terminal window
ansible -i hosts.ini managed -m ansible.builtin.ping

A successful result looks like this:

ec2 | SUCCESS => {
"changed": false,
"ping": "pong"
}

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.

  1. Gather system facts. The setup module collects information about the managed node: OS version, memory, CPU, network interfaces, and more. The filter argument 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.

  2. Check disk usage:

    Terminal window
    ansible -i hosts.ini managed -m ansible.builtin.command -a "df -h /"

    Notice that the output begins with "changed": true even though df reads disk usage and changes nothing at all. In this ad-hoc form, the command module runs a shell-free command without inspecting remote state first, so Ansible reports it as changed by default.

  3. Create a file with your name. Replace YOUR NAME with 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": true and a green changed label.

  4. 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 stdout field.

  5. 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": false and a yellow ok label. The file already exists with exactly that content, so Ansible made no changes.


The apt module manages packages on Debian-based systems. Package management requires root privileges, which Ansible requests with the --become flag.

  1. Install the tree package:

    Terminal window
    ansible -i hosts.ini managed -m ansible.builtin.apt \
    -a "name=tree state=present update_cache=yes cache_valid_time=3600" --become

    The output shows "changed": true.

  2. Verify the installation:

    Terminal window
    ansible -i hosts.ini managed -m ansible.builtin.command -a "tree --version"
  3. 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" --become

    Because you just refreshed the package cache and installed the package, this immediate second run should show "changed": false. The apt module found tree already installed at the requested state and the cache still fresh, so it did nothing.

  4. Remove the package:

    Terminal window
    ansible -i hosts.ini managed -m ansible.builtin.apt \
    -a "name=tree state=absent" --become

    This demonstrates the state=absent pattern: 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.


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.

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.

  1. Create the first version of site.yml:

    ---
    - name: Configure web server
    hosts: managed
    become: true
    tasks:
    - name: Install nginx and curl
    ansible.builtin.apt:
    name:
    - nginx
    - curl
    state: present
    update_cache: true
    cache_valid_time: 3600
    - name: Ensure nginx is running and enabled
    ansible.builtin.service:
    name: nginx
    state: started
    enabled: true

    Save the file in ~/cs312-ansible/site.yml.

  2. Run the linter against this first version:

    Terminal window
    ansible-lint site.yml

    A 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.apt instead of the short form apt. 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 run ansible-lint site.yml --fix to automatically apply some fixes.

  3. Preview what this version will do:

    Terminal window
    ansible-playbook -i hosts.ini site.yml --check --diff

    This 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.

  4. Run the playbook:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    On the first run, the package task should show changed, and the service task may also show changed if nginx was not already started.

  5. Run the same playbook a second time right away:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    On 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 for ok (found the system already correct), changed (took an action), failed, and skipped. On this second run, changed should be zero. The server is already in the declared state, so Ansible had nothing to do. A PLAY RECAP with changed=0 on a re-run is idempotency made visible.

  6. 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.

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.

  1. Replace site.yml with this second version. Replace Your Name with your actual name before saving:

    ---
    - name: Configure web server
    hosts: managed
    become: true
    vars:
    student_name: "Your Name"
    tasks:
    - name: Install nginx and curl
    ansible.builtin.apt:
    name:
    - nginx
    - curl
    state: present
    update_cache: true
    cache_valid_time: 3600
    - name: Deploy custom index page
    ansible.builtin.copy:
    content: "<h1>Deployed by {{ student_name }} on {{ ansible_hostname }}</h1>\n"
    dest: /var/www/html/index.html
    owner: www-data
    group: www-data
    mode: "0644"
    notify: Reload nginx
    - name: Ensure nginx is running and enabled
    ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
    handlers:
    - name: Reload nginx
    ansible.builtin.service:
    name: nginx
    state: reloaded
  2. Lint the updated playbook:

    Terminal window
    ansible-lint site.yml
  3. Preview the file change before applying it:

    Terminal window
    ansible-playbook -i hosts.ini site.yml --check --diff

    You should see a diff for /var/www/html/index.html showing the default page being replaced with your custom heading.

  4. Run the updated playbook:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    This time the page task should show changed, and the Reload nginx handler should appear once near the end of the play.

  5. Run the updated playbook a second time:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    Now every task should show ok, and the handler should not appear. Nothing about the page changed, so Ansible has no reason to reload nginx.

  6. 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.

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.

  1. Replace site.yml with the final version:

    ---
    - name: Configure web server
    hosts: managed
    become: true
    vars:
    student_name: "Your Name"
    tasks:
    - name: Install nginx and curl
    ansible.builtin.apt:
    name:
    - nginx
    - curl
    state: present
    update_cache: true
    cache_valid_time: 3600
    - name: Deploy custom index page
    ansible.builtin.copy:
    content: "<h1>Deployed by {{ student_name }} on {{ ansible_hostname }}</h1>\n"
    dest: /var/www/html/index.html
    owner: www-data
    group: www-data
    mode: "0644"
    notify: Reload nginx
    - name: Ensure nginx is running and enabled
    ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
    - name: Install nginx health-check script
    ansible.builtin.copy:
    content: |
    #!/usr/bin/env bash
    set -euo pipefail
    STATUS=$(/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.log
    dest: /usr/local/bin/nginx-health.sh
    owner: root
    group: root
    mode: "0755"
    - name: Schedule nginx health check
    ansible.builtin.cron:
    name: nginx health check
    minute: "*"
    user: root
    job: /usr/local/bin/nginx-health.sh
    cron_file: nginx-health
    handlers:
    - name: Reload nginx
    ansible.builtin.service:
    name: nginx
    state: reloaded

    The cron_file parameter writes the job to /etc/cron.d/nginx-health as a system cron file rather than a user crontab. The name field (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.

  2. Lint the final playbook:

    Terminal window
    ansible-lint site.yml
  3. Preview the final additions:

    Terminal window
    ansible-playbook -i hosts.ini site.yml --check --diff

    You should see changed predictions for the health-check script and the cron entry. The page task should usually remain ok because you already applied that change in the previous pass.

  4. Run the final playbook:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    The health-check script and cron task should show changed on this first final run.

  5. Run the final playbook a second time right away:

    Terminal window
    ansible-playbook -i hosts.ini site.yml

    If 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.

  6. 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: 200

    Status 200 means nginx is responding correctly. If the file is still empty, wait one more minute and run the command again.


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.

  1. Create the group_vars directory for the managed group:

    Terminal window
    mkdir -p group_vars/managed

    Ansible automatically loads all YAML files inside group_vars/<groupname>/ and makes their values available to every play targeting that group.

  2. Write the variable as plain text, then encrypt the file. Replace Your Name with your actual name before running:

    Terminal window
    echo 'student_name: "Your Name"' > group_vars/managed/secrets.yml
    ansible-vault encrypt group_vars/managed/secrets.yml

    Ansible will prompt you to set and confirm a vault password. Choose anything you will remember for the rest of this activity.

  3. Inspect the encrypted file:

    Terminal window
    cat group_vars/managed/secrets.yml

    Instead of your name, you will see something like:

    $ANSIBLE_VAULT;1.1;AES256
    62303862363832653831623366383064316135613164393836383432393437363366623763373832
    ...

    This is what would be committed to Git. Anyone without the vault password sees only this ciphertext.

  4. Remove the vars block from site.yml. Delete these three lines:

    vars:
    student_name: "Your Name"

    Ansible will now load student_name from the encrypted group_vars file instead of from the playbook.

  5. Run the playbook with the vault password:

    Terminal window
    ansible-playbook -i hosts.ini site.yml --ask-vault-pass

    Enter the password you set in step 2. The PLAY RECAP should show changed=0: the deployed page has not changed because student_name resolved 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.

  6. 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>

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 template module and Jinja2 templating let you render configuration files differently per host. Replace the inline content: in the index page task with a .j2 template file to see how templates differ from static copies.
  • Ansible Galaxy hosts community-maintained roles. Try installing geerlingguy.nginx and applying it, then compare its structure to the nginx task you wrote by hand.