100% Free Forever
AI-Powered Learning
Industry Expert Content
Certificates & Badges
Learn At Your Own Pace
Infrastructure as Code — Terraform & Ansible
55 minintermediate

Practice — Configure Nginx, ufw and chrony on 3 Ubuntu Servers

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.

Analogy🏏Cricket
🏏 Think of it like cricket: You are designing the BCCI's standard cricket ground specification module — a parameterisable blueprint that can be instantiated for any venue in the country without redrawing it from scratch. The blueprint fixes the non-negotiables every certified ground shares: a boundary perimeter (the VPC and its CIDR), spectator zones with public access (public subnets with an Internet Gateway route), and restricted player-and-officials zones behind accreditation checks (private subnets routing through NAT). What varies per venue arrives as parameters: how many stands to build (var.azs and subnet counts), whether this is a full international stadium or a modest district ground (var.single_nat_gateway as the cost lever — one shared service corridor instead of one per stand), and the venue's name and signage (tags). Wankhede and a Ranchi district ground are instantiated from the same drawing with different inputs — and when the board later improves the blueprint (adds flow logs, tightens NACLs), every venue inherits the improvement on its next renovation (module version bump) instead of each ground hand-patching its own architecture. That is the entire economics of module authorship: design once, rigorously, then stamp out consistent grounds forever.

Project Structure

bash
#!/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 -la

Step 1 — Templates

jinja2
# 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;
        }
    }
}
jinja2
# 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 }}/24

Step 2 — Main Playbook

yaml
# 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]
Analogy🏏Cricket
🏏 Think of it like cricket: The playbook you just assembled exercises the two disciplines that separate a professional session plan from a list of chores. First, notice which tasks notify handlers: only the ones that can actually change Nginx's configuration (the template render, the vhost deployment) flag 'Restart Nginx' — and the restart happens once, at the end, only if something really changed. That is the drinks-break protocol in action: three events can each flag that the keeper's gloves need replacing, but the gloves are replaced once at the break, and not at all if nobody flagged (an unchanged config triggers zero restarts — run the playbook twice and the second run should report changed=0 with no service disruption). Second, notice the firewall ordering: allow rules for SSH and HTTP are inserted BEFORE ufw is enabled, the way stewards confirm every accredited entrance is staffed before the perimeter lockdown is declared — enable the firewall first and you are the security chief who locks the stadium with the teams still outside (locked out of SSH on all three servers simultaneously, with no way back in except the console). Idempotent change detection plus safe ordering is the whole game of configuration management, and this exercise makes you play both.

Step 3 — Run and Verify

bash
#!/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' \
  --become

Warning: 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.
Lesson 20 of 33
0% complete