What You'll Build
You will write a complete, idempotent Ansible playbook that configures three Ubuntu servers with the cricket analytics platform baseline: Nginx as the reverse proxy (with a Jinja2-rendered virtual host configuration), ufw (Uncomplicated Firewall) with least-privilege rules allowing only required ports, and chrony for NTP time synchronisation pointing to the AWS Time Sync Service. The playbook uses handlers for graceful service management, Ansible Vault for the SSL certificate private key, and tags for selective execution. This exercise combines everything from Modules 4's Lessons 1-5 into a coherent, production-representative playbook. You will run the playbook against three instances (or localhost with connection=local for practice), verify idempotency by running it twice, and use check mode to preview changes before applying.
Project Structure
#!/bin/bash
# Set up the playbook project structure
mkdir -p ~/cricket_baseline/{inventory/group_vars,templates,files,handlers}
cd ~/cricket_baseline
cat > inventory/hosts.yml << 'INV'
all:
children:
cricket_api:
hosts:
cricket-api-1:
ansible_host: 10.0.1.10
server_id: 1
cricket-api-2:
ansible_host: 10.0.1.11
server_id: 2
cricket-api-3:
ansible_host: 10.0.1.12
server_id: 3
vars:
ansible_user: ubuntu
ansible_ssh_private_key_file: ~/.ssh/cricket-key.pem
ansible_python_interpreter: /usr/bin/python3
INV
cat > inventory/group_vars/cricket_api.yml << 'GVARS'
---
environment: production
app_port: 8080
nginx_worker_processes: auto
nginx_keepalive_timeout: 65
ntp_server: 169.254.169.123 # AWS Time Sync Service
firewall_allowed_ports:
- { port: 22, proto: tcp, comment: 'SSH (restrict to bastion in production)' }
- { port: 80, proto: tcp, comment: 'HTTP — redirects to HTTPS' }
- { port: 443, proto: tcp, comment: 'HTTPS — application traffic' }
- { port: 8080, proto: tcp, comment: 'App port — internal only', src: '10.0.0.0/8' }
GVARS
touch inventory/group_vars/vault.yml # Will contain encrypted SSL key
touch templates/nginx-vhost.conf.j2
touch templates/chrony.conf.j2
echo 'Structure created'
ls -laStep 1 — Templates
# templates/nginx-vhost.conf.j2 — Cricket Analytics Nginx virtual host
# Rendered by Ansible — do not edit directly on servers
# Host: {{ inventory_hostname }} | Environment: {{ environment }}
worker_processes {{ nginx_worker_processes }};
events {
worker_connections 1024;
}
http {
include /etc/nginx/mime.types;
default_type application/octet-stream;
sendfile on;
keepalive_timeout {{ nginx_keepalive_timeout }};
# Logging
access_log /var/log/nginx/cricket-access.log;
error_log /var/log/nginx/cricket-error.log warn;
# Upstream: Cricket Analytics API
upstream cricket_api {
server 127.0.0.1:{{ app_port }};
keepalive 32;
}
# HTTP → HTTPS redirect
server {
listen 80;
server_name _;
return 301 https://$host$request_uri;
}
# HTTPS server
server {
listen 443 ssl;
server_name {{ inventory_hostname }};
ssl_certificate /etc/ssl/cricket/fullchain.pem;
ssl_certificate_key /etc/ssl/cricket/privkey.pem;
ssl_protocols TLSv1.2 TLSv1.3;
# Health check — no auth required
location /health {
access_log off;
add_header Content-Type application/json;
return 200 '{"status":"ok","server":{{ server_id }},"env":"{{ environment }}"}' ;
}
# Main application proxy
location / {
proxy_pass http://cricket_api;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
}# templates/chrony.conf.j2 — NTP time synchronisation
# Rendered by Ansible | Host: {{ inventory_hostname }}
# AWS Time Sync Service — highly accurate, no internet required
server {{ ntp_server }} prefer iburst
# Fallback: public NTP pools
{% for i in range(4) %}
pool {{ i }}.ubuntu.pool.ntp.org iburst
{% endfor %}
# Allow NTP client to step the clock if the offset is large
makestep 1.0 3
# Enable kernel time discipline
rtcsync
# Logging
logdir /var/log/chrony
log tracking measurements statistics
# Client access (for other hosts on the subnet)
allow {{ ansible_default_ipv4.network }}/24Step 2 — Main Playbook
# configure_baseline.yml — Cricket Analytics server baseline
---
- name: Configure Cricket Analytics API server baseline
hosts: cricket_api
become: true
gather_facts: true
handlers:
- name: Restart Nginx
ansible.builtin.service:
name: nginx
state: restarted
listen: restart nginx
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
listen: reload nginx
- name: Restart chrony
ansible.builtin.service:
name: chrony
state: restarted
listen: restart chrony
- name: Reload ufw
ansible.builtin.command: ufw reload
changed_when: true
listen: reload ufw
tasks:
# ── System update and packages ────────────────────────────────────────────
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600 # Only update if cache is older than 1 hour
tags: [packages]
- name: Install required packages
ansible.builtin.apt:
name:
- nginx
- ufw
- chrony
- curl
- jq
- python3-pip
state: present
tags: [packages]
# ── SSL certificate directory ─────────────────────────────────────────────
- name: Create SSL certificate directory
ansible.builtin.file:
path: /etc/ssl/cricket
state: directory
owner: root
group: root
mode: '0700'
tags: [nginx, ssl]
- name: Deploy SSL private key (from vault)
ansible.builtin.copy:
content: '{{ ssl_private_key }}'
dest: /etc/ssl/cricket/privkey.pem
owner: root
group: root
mode: '0600'
no_log: true # Suppress task output to prevent key leakage in logs
notify: reload nginx
tags: [nginx, ssl]
- name: Deploy SSL certificate chain
ansible.builtin.copy:
src: files/fullchain.pem
dest: /etc/ssl/cricket/fullchain.pem
owner: root
group: root
mode: '0644'
notify: reload nginx
tags: [nginx, ssl]
# ── Nginx configuration ───────────────────────────────────────────────────
- name: Deploy Nginx configuration
ansible.builtin.template:
src: templates/nginx-vhost.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
validate: 'nginx -t -c %s'
backup: true
notify: reload nginx
tags: [nginx]
- name: Remove default Nginx site
ansible.builtin.file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: reload nginx
tags: [nginx]
- name: Enable and start Nginx
ansible.builtin.service:
name: nginx
enabled: true
state: started
tags: [nginx]
# ── ufw firewall ──────────────────────────────────────────────────────────
- name: Reset ufw to defaults (ensure clean state)
community.general.ufw:
state: reset
changed_when: false
tags: [firewall]
- name: Set ufw default policies
community.general.ufw:
direction: '{{ item.direction }}'
policy: '{{ item.policy }}'
loop:
- { direction: incoming, policy: deny } # Block all inbound by default
- { direction: outgoing, policy: allow } # Allow all outbound by default
- { direction: routed, policy: deny }
tags: [firewall]
- name: Configure firewall rules from inventory variable
community.general.ufw:
rule: allow
port: '{{ item.port }}'
proto: '{{ item.proto }}'
from_ip: '{{ item.src | default("any") }}'
comment: '{{ item.comment }}'
loop: '{{ firewall_allowed_ports }}'
loop_control:
label: 'port {{ item.port }}/{{ item.proto }}'
notify: reload ufw
tags: [firewall]
- name: Enable ufw
community.general.ufw:
state: enabled
tags: [firewall]
# ── chrony NTP ────────────────────────────────────────────────────────────
- name: Deploy chrony configuration
ansible.builtin.template:
src: templates/chrony.conf.j2
dest: /etc/chrony/chrony.conf
owner: root
group: root
mode: '0644'
backup: true
notify: restart chrony
tags: [chrony]
- name: Enable and start chrony
ansible.builtin.service:
name: chrony
enabled: true
state: started
tags: [chrony]
# ── Verification ──────────────────────────────────────────────────────────
- name: Verify Nginx is responding
ansible.builtin.uri:
url: 'http://localhost/health'
status_code: [200, 301] # 301 = redirect to HTTPS (expected without SSL setup)
timeout: 10
retries: 3
delay: 5
register: nginx_check
until: nginx_check.status in [200, 301]
tags: [verify]
- name: Verify NTP synchronisation
ansible.builtin.command: chronyc tracking
register: chrony_tracking
changed_when: false
tags: [verify]
- name: Show NTP synchronisation status
ansible.builtin.debug:
msg: '{{ chrony_tracking.stdout_lines[0:3] }}'
tags: [verify]
- name: Verify ufw status
ansible.builtin.command: ufw status numbered
register: ufw_status
changed_when: false
tags: [verify]
- name: Show firewall rules
ansible.builtin.debug:
msg: '{{ ufw_status.stdout_lines }}'
tags: [verify]Step 3 — Run and Verify
#!/bin/bash
cd ~/cricket_baseline
# Encrypt the SSL private key with Ansible Vault
ansible-vault encrypt_string "$(cat /path/to/privkey.pem)" \
--name ssl_private_key >> inventory/group_vars/vault.yml
echo '=== Step 1: Syntax check (no connection needed) ==='
ansible-playbook configure_baseline.yml --syntax-check
echo
echo '=== Step 2: Dry run with diff output ==='
ansible-playbook \
-i inventory/ \
configure_baseline.yml \
--check --diff \
--vault-password-file ~/.ansible-vault-password
# Review what would change on each server before applying
echo
echo '=== Step 3: Apply to all three servers ==='
ansible-playbook \
-i inventory/ \
configure_baseline.yml \
--vault-password-file ~/.ansible-vault-password \
-v # Verbose: shows task output
echo
echo '=== Step 4: Verify idempotency (re-run) ==='
ansible-playbook \
-i inventory/ \
configure_baseline.yml \
--vault-password-file ~/.ansible-vault-password 2>&1 | \
grep -E 'ok=|changed=|failed=|PLAY RECAP'
# Every changed= should be 0 — all tasks report 'ok' on second run
# If any changed= > 0, the playbook is not idempotent — investigate that task
echo
echo '=== Step 5: Apply only firewall changes (tags) ==='
ansible-playbook \
-i inventory/ \
configure_baseline.yml \
--tags firewall \
--vault-password-file ~/.ansible-vault-password
# Only ufw-related tasks run — Nginx and chrony tasks are skipped
echo
echo '=== Step 6: Ad-hoc verification ==='
# Check Nginx status on all hosts
ansible cricket_api \
-i inventory/ \
-m ansible.builtin.command \
-a 'systemctl is-active nginx'
# Check firewall rules
ansible cricket_api \
-i inventory/ \
-m ansible.builtin.command \
-a 'ufw status numbered' \
--becomeWarning: The 'community.general.ufw: state=reset' task reverts the firewall to default (deny all inbound) before the allow rules are applied. If the playbook fails after the reset but before the SSH allow rule is applied, you will lose SSH access to the server. Test this playbook on non-production instances first, and always run with '--check --diff' before applying. In production, use serial: 1 in the play to apply to servers one at a time, verifying each before continuing. If the server becomes unreachable after a ufw reset, use the AWS console's EC2 Instance Connect or SSM Session Manager to access it without SSH.
- Idempotency is the key quality standard — run the playbook twice and verify that the second run shows changed=0 for all tasks; any non-zero changed= indicates a task that needs reviewing.
- Use 'no_log: true' on tasks that handle sensitive data (SSL private keys, passwords) to prevent the values from appearing in Ansible output logs or callback plugins.
- Use 'serial: 1' at the play level for firewall changes to apply and verify changes on one server before proceeding — a firewall misconfiguration that locks out SSH can be caught after the first server without affecting all three.
- The 'validate' parameter on template and copy tasks runs a validation command before replacing the live file — for Nginx, 'nginx -t -c %s' prevents deploying an invalid configuration that would stop Nginx.
- Tags enable targeted partial runs — '--tags nginx' only runs Nginx tasks, '--tags firewall' only runs ufw tasks — reducing playbook run time for day-to-day configuration changes.