Command Palette

Search for a command to run...

Hectal

Mission 0.2 · Stage 0 — Configuration Management with Ansible

Playbooks, Idempotency, and Handlers

Goal: A playbook that installs and configures Nginx on every web server, safe to run any number of times.

40 min Free 5 steps 2 break-it drills

By the end of this mission

  • Structure a playbook: plays, hosts, tasks, modules
  • Make every task idempotent, including shell commands
  • Restart services only when their config actually changes, using handlers
  • Preview changes with --check and --diff before applying

Part 1

Understand it first

Plays, tasks, and modules

A PLAYBOOK is a YAML list of PLAYS. Each play maps a group of hosts to an ordered list of TASKS, and each task calls one module with arguments. Tasks run in order, and each task runs on all targeted hosts (in parallel, up to forks, default 5) before the next task starts.

Ad-hoc commands are for questions; playbooks are for state you want to keep. A playbook in Git is reviewable, repeatable documentation of how a server is built.

Handlers: react to change, not to every run

Restarting Nginx on every run causes needless blips. A HANDLER is a task that only runs when another task notifys it AND that task reported changed. Handlers run once at the end of the play, even if notified several times, so ten config changes cause one reload.

How one playbook run flowsdiagram
Rendering diagram…

Part 2

Your project after this mission · 2 files change

shoplite-gitops/
  • ansible/
    • files/
      • shoplite.confnew
    • ansible.cfg
    • inventory.ini
    • site.ymlnew

Part 3

Build it, step by step

  1. 1

    Write the Nginx site config

    A plain file for now; in the next mission it becomes a template with variables. It's the same reverse-proxy setup you built in the Networking course (Topic 5.2).

    ansible/files/shoplite.confwhole filenginx
    server {
        listen 80 default_server;
        location /healthz { return 200 "ok\n"; }
        location / {
            proxy_pass http://127.0.0.1:8080;
            proxy_set_header Host $host;
            proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
        }
    }
  2. 2

    Write the playbook

    become: true runs tasks with sudo (harmless in the root containers, required on real servers). Every task has a name. It appears in the output and is the task's documentation.

    The command task that validates config has changed_when: false: it only reads, so it must never report a change. Without that, every run would look like it changed something.

    ansible/site.ymlwhole fileyaml
    - name: Configure ShopLite web servers
      hosts: web
      become: true
    
      tasks:
        - name: Install nginx
          ansible.builtin.apt:
            name: nginx
            state: present
            update_cache: true
            cache_valid_time: 3600
    
        - name: Deploy the ShopLite site
          ansible.builtin.copy:
            src: files/shoplite.conf
            dest: /etc/nginx/sites-available/default
            mode: "0644"
          notify: Reload nginx
    
        - name: Validate nginx config
          ansible.builtin.command: nginx -t
          changed_when: false
    
        - name: Ensure nginx is running
          ansible.builtin.service:
            name: nginx
            state: started
    
      handlers:
        - name: Reload nginx
          ansible.builtin.service:
            name: nginx
            state: reloaded
  3. 3

    Check syntax and preview with --check --diff

    --check is a dry run: modules report what they WOULD change. --diff shows file content differences. Together they are Ansible's version of terraform plan. Not every module supports check mode perfectly (commands are skipped), so treat it as a strong preview, not a guarantee.

    terminal
    $ ansible-playbook site.yml --syntax-check
    ansible-playbook site.yml --check --diff
    ── expected output ──
    playbook: site.yml
    ...
    TASK [Deploy the ShopLite site] ***
    --- before: /etc/nginx/sites-available/default
    +++ after: files/shoplite.conf
    @@ ... @@
    changed: [web1]
    changed: [web2]
  4. 4

    Apply it, then apply it again

    The first run changes things and fires the handler once per host. The second run should be all green: changed=0. A playbook that reports changes on every run is lying to you, because you can no longer tell a real change from noise.

    terminal
    $ ansible-playbook site.yml
    ansible-playbook site.yml | tail -4
    ── expected output ──
    RUNNING HANDLER [Reload nginx] ***
    changed: [web1]
    changed: [web2]
     
    PLAY RECAP ***
    web1 : ok=5 changed=3 unreachable=0 failed=0 skipped=0
    web2 : ok=5 changed=3 unreachable=0 failed=0 skipped=0
     
    PLAY RECAP ***
    web1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0
    web2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0
  5. 5

    Target, limit, and tag

    --limit web1 runs on one host. That's how you canary a change on one server before the fleet. --start-at-task resumes a long run. serial: 1 in the play would roll changes out host by host, which is the configuration-management version of a rolling update (Kubernetes course).

    terminal
    $ ansible-playbook site.yml --limit web1 --diff
    ── expected output ──
    PLAY RECAP ***
    web1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0

Checkpoint — you should now have

  • ✓curl inside a container (docker exec web1 curl -s localhost/healthz) returns ok.
  • ✓A second ansible-playbook site.yml run reports changed=0 on every host.
  • ✓Editing shoplite.conf and re-running triggers exactly one reload per host.

Part 4

Break it on purpose

Make each change, run the command, and read the error before revealing the diagnosis. Recognising these messages on sight is what makes you fast on a real team. Undo the change afterwards.

Break #1

Make a task that always reports changed

Remove changed_when: false from the validate task and run the playbook twice.

terminal
$ ansible-playbook site.yml | tail -3
── what you'll see ──
PLAY RECAP ***
web1 : ok=4 changed=1 unreachable=0 failed=0 skipped=0
web2 : ok=4 changed=1 unreachable=0 failed=0 skipped=0

Break #2

Ship a broken config

Delete the closing } from shoplite.conf and run the playbook.

terminal
$ ansible-playbook site.yml
── what you'll see ──
TASK [Validate nginx config] ***
fatal: [web1]: FAILED! => {"cmd": ["nginx", "-t"], "rc": 1,
"stderr": "nginx: [emerg] unexpected end of file, expecting \"}\" in /etc/nginx/sites-enabled/default:9"}
...
NO MORE HOSTS LEFT ***

Part 5

Interview questions from this mission

01

What's the difference between a task and a handler?

02

How do you roll out a risky Ansible change safely across 200 servers?

0/4 · 0%