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.
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.
Part 2
Your project after this mission · 2 files change
- ansible/
- files/
- shoplite.confnew
- ansible.cfg
- inventory.ini
- site.ymlnew
Part 3
Build it, step by step
- 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
Write the playbook
become: trueruns tasks with sudo (harmless in the root containers, required on real servers). Every task has aname. It appears in the output and is the task's documentation.The
commandtask that validates config haschanged_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
Check syntax and preview with --check --diff
--checkis a dry run: modules report what they WOULD change.--diffshows file content differences. Together they are Ansible's version ofterraform 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-checkansible-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
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.ymlansible-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=0web2 : ok=5 changed=3 unreachable=0 failed=0 skipped=0PLAY RECAP ***web1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0web2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 - 5
Target, limit, and tag
--limit web1runs on one host. That's how you canary a change on one server before the fleet.--start-at-taskresumes a long run.serial: 1in 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
- ✓
curlinside a container (docker exec web1 curl -s localhost/healthz) returns ok. - ✓A second
ansible-playbook site.ymlrun reportschanged=0on 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.
Break #2
Ship a broken config
Delete the closing } from shoplite.conf and run the playbook.
Part 5
Interview questions from this mission
What's the difference between a task and a handler?
How do you roll out a risky Ansible change safely across 200 servers?