Automation with Ansible¶
Level 6 · Chapter 8 · ⏱️ ~75 min read · Prerequisites: SSH, systemd and journalctl, Firewalls with ufw, Virtualization
This chapter turns the server you built by hand in Level 4 into code. You will learn why infrastructure as code and idempotency matter, how Ansible works over plain SSH, and how to write inventories, ad-hoc commands, playbooks, handlers, templates, roles, and encrypted secrets. It ends with a playbook that rebuilds the whole Level 4 capstone server from scratch in a few minutes.
Why it matters¶
For the Level 4 capstone you spent an evening setting up a server: SSH hardening, ufw, an app user, a systemd service, and a backup timer. It worked. Three months later the VM's disk dies. You open your notes and find gaps. Did you set MaxAuthTries? Which port did the app use? What was in the backup script? You rebuild it from memory, and it comes out almost the same.
Now picture three servers built that way by three people over a year. Each one is slightly different. That is called configuration drift. A fix applied to one server is forgotten on the others. When something breaks at 2 a.m., nobody knows what "correct" looks like.
With Ansible, the server's configuration lives in a few text files in Git. Rebuilding is ansible-playbook site.yml, three minutes later you are done, and the result is identical every time. Need a second server? Add one line to the inventory. Want to know whether production still matches? Run with --check --diff, and Ansible lists every difference without changing anything.
Concepts¶
Infrastructure as code¶
Infrastructure as code (IaC) means describing servers, networks, and their configuration in files that a tool applies, instead of typing commands by hand. The files get the same treatment as application code:
- Version control.
git logshows who changed the SSH config, when, and why. - Review. Changes go through pull requests before they reach production.
- Repeatability. The same files produce the same server, today or in two years.
- Documentation. The code is the up-to-date description of the system.
You have already seen IaC on a small scale: a cloud-init user-data file in Virtualization, and a Dockerfile in Docker and Podman. Ansible applies the idea to whole fleets of running servers.
Idempotency¶
An operation is idempotent if running it once or many times gives the same result. This is the most important idea in configuration management.
Compare two ways to set a sysctl:
# not idempotent: every run appends another copy of the line
echo "vm.swappiness = 10" | sudo tee -a /etc/sysctl.d/99-tuning.conf
# idempotent: describe the desired state; a tool adds the line only if it is missing
# lineinfile: path=/etc/sysctl.d/99-tuning.conf line="vm.swappiness = 10"
Run the first one five times and the file has five identical lines. The second checks first and changes the file only when needed.
Idempotency is what makes automation safe to re-run. You can apply the same playbook every hour and nothing happens unless something drifted. Then Ansible fixes exactly that, and reports it as changed.
Ansible modules are declarative: you describe the desired state ("nginx is installed", "this line is present", "this service is running and enabled"), not the steps. The module works out the steps. Shell scripts are imperative: they list steps, and making them idempotent is your job.
How Ansible works¶
Ansible is an open-source automation tool written in Python. Its key design choice is that it is agentless: you install nothing special on the servers it manages. It needs only:
- SSH access to each server (with the keys you set up in SSH), and
- Python 3 on each server, which every Ubuntu and Mint install already has.
The machine where you install Ansible and run it is the control node. Your Mint laptop is a fine control node. The servers it configures are managed nodes. The list of managed nodes is the inventory.
flowchart LR
subgraph control["Control node (your Mint laptop)"]
PB["playbook + roles (YAML, in Git)"]
INV["inventory"]
ANS["ansible-playbook"]
end
subgraph managed["Managed nodes (only need SSH + Python)"]
W1["web1 192.168.122.57"]
W2["web2 192.168.122.58"]
D1["db1 192.168.122.59"]
end
PB --> ANS
INV --> ANS
ANS -- "SSH" --> W1
ANS -- "SSH" --> W2
ANS -- "SSH" --> D1
When Ansible runs one task on one host, this happens:
sequenceDiagram
participant C as Control node
participant M as Managed node
C->>M: SSH connect (key auth)
C->>M: copy module code (Python) to ~/.ansible/tmp
C->>M: run it with python3, through sudo if become is on
M-->>C: JSON result: changed, failed, facts, messages
C->>M: delete the temporary files
A module is a small program that does one kind of job, such as installing a package, copying a file, or managing a service. It runs on the managed node, compares the current state with the desired state, makes changes only if needed, and reports back in JSON. Ansible ships thousands of modules in collections: ansible.builtin (always present), ansible.posix, community.general, and many more.
This is a push model: you run Ansible when you want changes, and it connects out to the servers. Tools like Puppet and Chef use a pull model, where an agent on each server fetches its configuration periodically.
The vocabulary¶
| Term | Meaning |
|---|---|
| Inventory | The hosts Ansible manages, sorted into groups |
| Module | A unit of work, such as apt, copy, or service |
| Task | One call to a module with arguments, plus a human-readable name |
| Play | A list of tasks applied to a group of hosts |
| Playbook | A YAML file with one or more plays |
| Handler | A task that runs only when notified by a task that changed something, such as "restart nginx" |
| Facts | Information Ansible gathers about each host (OS, IPs, memory) at the start of a play |
| Variables | Named values that make playbooks reusable |
| Template | A file with Jinja2 placeholders, filled in per host |
| Role | A reusable bundle of tasks, handlers, templates, files, and defaults |
| Become | Privilege escalation, normally sudo |
Where Ansible fits among other tools¶
| Tool | Job | Typical use |
|---|---|---|
| cloud-init | First-boot setup, run once inside a new VM | Create a user, add SSH keys, install the basics |
| Terraform / OpenTofu | Provisioning: create and destroy infrastructure through cloud APIs | VMs, networks, DNS records, load balancers |
| Ansible | Configuration management: configure what runs on existing machines | Packages, files, services, users, firewalls |
| NixOS | The whole OS defined declaratively in one config, rebuilt atomically | Fully reproducible machines, with rollback to any earlier generation |
A common real-world pipeline: Terraform creates VMs, cloud-init gives them a user and an SSH key, and Ansible configures everything else. Containers often take over the application layer, while Ansible prepares the hosts that run them.
Commands and examples¶
⚠️ VM only
Ansible makes real changes as root on every host in your inventory, and a wrong host pattern can hit machines you didn't intend. Practice against throwaway VMs only. Never put your main machine in the inventory. Run Ansible from your Mint machine (the control node; installing Ansible there is safe), against VMs.
Lab setup¶
You need one or two Ubuntu 24.04 VMs that you can reach with SSH as alex using a key, with passwordless sudo. The cloud-init method from Virtualization gives you exactly that. This chapter assumes:
| Host | Address | Role |
|---|---|---|
web1 |
192.168.122.57 |
Web server |
web2 |
192.168.122.58 |
Web server |
Check that plain SSH works first (ssh alex@192.168.122.57 true). Ansible cannot fix a broken SSH setup.
Installing Ansible¶
Two good options on the control node:
This installs the ansible package (version 9.x on Ubuntu 24.04), which includes ansible-core 2.16 and a set of popular collections such as community.general and ansible.posix. It is stable but not the newest.
pipx installs a Python application into its own private virtual environment and puts its commands on your PATH. --include-deps also exposes the commands of ansible-core, such as ansible-playbook. Upgrade later with pipx upgrade --include-injected ansible.
Check it:
ansible [core 2.16.3]
config file = None
configured module search path = ['/home/alex/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/lib/python3/dist-packages/ansible
ansible collection location = /home/alex/.ansible/collections:/usr/share/ansible/collections
executable location = /usr/bin/ansible
python version = 3.12.3 (main, ...) [GCC 13.2.0] (/usr/bin/python3)
jinja version = 3.1.2
libyaml = True
config file = None means no ansible.cfg was found. You will add one next. Nothing is installed on the managed nodes.
A project directory and ansible.cfg¶
Keep each Ansible project in its own directory, under Git:
Ansible looks for ansible.cfg in the current directory first. A small one saves typing:
# ~/ansible-lab/ansible.cfg
[defaults]
inventory = inventory.ini
remote_user = alex
host_key_checking = True
callback_result_format = yaml
retry_files_enabled = False
[privilege_escalation]
become_method = sudo
inventory sets the default inventory file, so you don't need -i every time. remote_user is the SSH user. Keep host_key_checking on. Connect to each VM once with plain ssh to accept its host key, rather than disabling the check that protects you from man-in-the-middle attacks. callback_result_format = yaml prints task results as readable YAML instead of dense JSON.
Inventory: INI and YAML¶
The inventory lists hosts and groups. The INI format is the most common:
# inventory.ini
[webservers]
web1 ansible_host=192.168.122.57
web2 ansible_host=192.168.122.58
[dbservers]
db1 ansible_host=192.168.122.59
[production:children]
webservers
dbservers
[webservers]is a group. A host can be in several groups.web1is the inventory name Ansible uses in output.ansible_hostis the address it actually connects to. You could also write plain IPs or DNS names.[production:children]makes a group of groups.- Two groups always exist:
all(every host) andungrouped.
The same inventory in YAML, which scales better once you have many variables:
# inventory.yml
all:
children:
webservers:
hosts:
web1:
ansible_host: 192.168.122.57
web2:
ansible_host: 192.168.122.58
dbservers:
hosts:
db1:
ansible_host: 192.168.122.59
production:
children:
webservers:
dbservers:
Check how Ansible understands it:
@all:
|--@ungrouped:
|--@production:
| |--@webservers:
| | |--web1
| | |--web2
| |--@dbservers:
| | |--db1
For the rest of this chapter, the lab inventory has only web1 and web2 in webservers.
host_vars and group_vars¶
Don't cram variables into the inventory file. Ansible automatically loads YAML files from two directories next to the inventory (or the playbook):
ansible-lab/
├── ansible.cfg
├── inventory.ini
├── group_vars/
│ ├── all.yml # every host
│ └── webservers.yml # hosts in [webservers]
└── host_vars/
└── web1.yml # only web1
A more specific level wins: host_vars beats group_vars/webservers.yml, which beats group_vars/all.yml. A file can also be a directory of files (group_vars/webservers/vars.yml, group_vars/webservers/vault.yml), which you will use for secrets.
Ad-hoc commands¶
An ad-hoc command runs one module against hosts from the command line, with no playbook. It is good for quick checks and one-off jobs. The form is ansible <pattern> -m <module> -a "<arguments>".
The first test is always ping. It is not ICMP ping: it checks that Ansible can SSH in and run Python.
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Hosts run in parallel (5 at a time by default; change it with -f 20), so output order varies.
Run a command (the default module is command, so -m command is optional):
web1 | CHANGED | rc=0 >>
10:41:07 up 2:13, 1 user, load average: 0.00, 0.01, 0.00
web2 | CHANGED | rc=0 >>
10:41:07 up 2:12, 1 user, load average: 0.08, 0.03, 0.01
CHANGED appears because Ansible cannot know what an arbitrary command did, so it assumes the worst. That is one reason to prefer real modules over command and shell.
Install a package. That needs root, so add -b (become, which uses sudo on the managed node):
Run it again: now it says SUCCESS with "changed": false, because htop is already present. That is idempotency in action. If your sudo asks for a password, add -K (--ask-become-pass).
More useful ad-hoc commands:
ansible web1 -m setup -a "filter=ansible_distribution*" # facts
ansible webservers -b -m service -a "name=cron state=restarted"
ansible webservers -m shell -a "df -h / | tail -1" # shell allows pipes
ansible 'webservers:!web2' -m ping # pattern: all webservers except web2
web1 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "24",
"ansible_distribution_release": "noble",
"ansible_distribution_version": "24.04"
},
"changed": false
}
Your first playbook¶
A playbook is a YAML file. Here is one play with three tasks:
# nginx.yml
- name: Install and run nginx
hosts: webservers
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy a home page
ansible.builtin.copy:
content: "<h1>Managed by Ansible on {{ inventory_hostname }}</h1>\n"
dest: /var/www/html/index.html
owner: root
group: root
mode: "0644"
- name: Ensure nginx is running and enabled at boot
ansible.builtin.service:
name: nginx
state: started
enabled: true
Reading it:
- The top level is a list of plays (
- name: ...). This file has one. hosts: webserverstargets that inventory group.become: trueruns every task withsudo.- Each task has a
name(shown in output) and one module, written by its fully qualified collection name (FQCN):ansible.builtin.apt. The short nameaptalso works, but FQCNs avoid ambiguity andansible-lintasks for them. cache_valid_time: 3600skipsapt updateif the cache is less than an hour old, which keeps re-runs fast.{{ inventory_hostname }}is a Jinja2 expression. Ansible fills in the host's inventory name.mode: "0644"is quoted. Unquoted, YAML would read0644as the octal number 420 and pass that on, which is a classic source of wrong permissions.
Run it:
PLAY [Install and run nginx] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
ok: [web2]
TASK [Install nginx] ***********************************************************
changed: [web1]
changed: [web2]
TASK [Deploy a home page] ******************************************************
changed: [web1]
changed: [web2]
TASK [Ensure nginx is running and enabled at boot] *****************************
ok: [web1]
ok: [web2]
PLAY RECAP *********************************************************************
web1 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=4 changed=2 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
ok means "already in the desired state". changed means "Ansible changed something". The service task says ok because installing nginx on Ubuntu already starts and enables it. Run the playbook a second time:
PLAY RECAP *********************************************************************
web1 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 is the goal of every second run. If a second run reports changes, something in the playbook is not idempotent.
Useful flags: --limit web1 runs on a subset, --start-at-task "Deploy a home page" resumes after a failure, -v/-vvv gives more detail (-vvvv shows SSH debugging), and --list-tasks and --list-hosts preview a run.
The modules you will use most¶
| Module | Desired state it manages | Example arguments |
|---|---|---|
ansible.builtin.apt |
Packages | name: [nginx, git], state: present/absent/latest |
ansible.builtin.copy |
A file with fixed content | src: files/app.py or content: ..., dest, owner, mode |
ansible.builtin.template |
A file rendered from a Jinja2 template | src: sshd.conf.j2, dest, mode, validate |
ansible.builtin.file |
Directories, symlinks, permissions, deletion | path, state: directory/link/absent/touch, mode |
ansible.builtin.lineinfile |
One line in an existing file | path, regexp, line |
ansible.builtin.user |
Users | name, system: true, shell, groups, append: true |
ansible.builtin.service |
Service state (works with any init system) | name, state: started/restarted/reloaded, enabled |
ansible.builtin.systemd_service |
systemd specifics | as service, plus daemon_reload: true |
community.general.ufw |
ufw rules and state | rule: allow, port: "22", proto: tcp, state: enabled |
ansible.posix.authorized_key |
SSH public keys | user, key |
ansible.builtin.command / shell |
Run anything (last resort) | cmd, creates: to make it idempotent |
ansible-doc ansible.builtin.lineinfile shows full documentation with examples, offline. ansible-doc -l | wc -l shows how many modules you have installed.
A lineinfile example. Its regexp finds an existing line to replace, so re-runs don't add duplicates:
- name: Lower swappiness
ansible.builtin.lineinfile:
path: /etc/sysctl.d/99-tuning.conf
regexp: '^vm\.swappiness'
line: "vm.swappiness = 10"
create: true
mode: "0644"
Common mistake
Reaching for shell first: shell: echo "vm.swappiness = 10" >> /etc/sysctl.d/99-tuning.conf. It always reports changed and appends a duplicate on every run. When you truly need command or shell, add creates: /path (skip the task if the file exists) or changed_when: so it stays honest.
Handlers¶
Some actions should happen only when something changed. Restarting nginx after every run would cause needless blips. Restarting it after its config changed is required. A handler is a task that runs only when another task notifies it and that task reported changed. Handlers run once, at the end of the play, even if notified several times.
- name: Configure nginx
hosts: webservers
become: true
tasks:
- name: Deploy nginx site config
ansible.builtin.template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/default
mode: "0644"
validate: nginx -t -c /etc/nginx/nginx.conf
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
validate tests the new file before it replaces the old one. Here it runs nginx -t against the main config. If validation fails, the task fails and the old config stays in place. Unchanged config means no notification, so there is no reload.
TASK [Deploy nginx site config] ************************************************
changed: [web1]
ok: [web2]
RUNNING HANDLER [Reload nginx] *************************************************
changed: [web1]
Only web1's config changed, so only web1 reloads.
Variables and facts¶
Variables can be defined in many places: inventory, group_vars/host_vars, a play's vars:, a role's defaults/, the command line (-e webapp_port=9000, which beats everything), and more. The full precedence list has over 20 levels. In practice, remember three rules: role defaults are the weakest, host_vars beats group_vars, and -e wins.
Facts are variables Ansible discovers on each host during "Gathering Facts", using the setup module. They live under ansible_facts:
- name: Show some facts
hosts: webservers
tasks:
- name: Print OS, memory, and main IP
ansible.builtin.debug:
msg: >-
{{ inventory_hostname }} runs {{ ansible_facts['distribution'] }}
{{ ansible_facts['distribution_version'] }} with
{{ ansible_facts['memtotal_mb'] }} MB RAM at
{{ ansible_facts['default_ipv4']['address'] }}
TASK [Print OS, memory, and main IP] *******************************************
ok: [web1] =>
msg: web1 runs Ubuntu 24.04 with 1967 MB RAM at 192.168.122.57
ok: [web2] =>
msg: web2 runs Ubuntu 24.04 with 1967 MB RAM at 192.168.122.58
You can also capture a task's result with register and use it later:
- name: Check whether a reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_flag
- name: Reboot if the kernel was updated
ansible.builtin.reboot:
when: reboot_flag.stat.exists
Jinja2 templates¶
Jinja2 is the templating language Ansible uses everywhere: in {{ }} expressions in playbooks and in template files. A template file (by convention ending in .j2) is rendered on the control node with the host's variables, then copied to the host.
{# templates/sshd-hardening.conf.j2 - this comment is not copied #}
# Managed by Ansible. Manual changes will be overwritten.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries {{ ssh_max_auth_tries | default(3) }}
AllowUsers {{ admin_users | join(' ') }}
{% if ssh_port is defined %}
Port {{ ssh_port }}
{% endif %}
The syntax:
{{ expression }}prints a value.{% statement %}is logic:if,for, and so on.{# ... #}is a comment.|applies a filter:default(3)gives a fallback, andjoin(' ')turns a list into a string. Others you'll use:upper,lower,int,bool,to_nice_yaml,password_hash('sha512'),regex_replace.
With admin_users: [alex], web1 gets:
# Managed by Ansible. Manual changes will be overwritten.
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
MaxAuthTries 3
AllowUsers alex
The "Managed by Ansible" header warns anyone editing the file on the server that their change will be overwritten on the next run.
Loops and conditionals¶
loop repeats a task for each item, available as item:
- name: Create admin users
ansible.builtin.user:
name: "{{ item.name }}"
groups: sudo
append: true
shell: /bin/bash
loop:
- { name: alex }
- { name: sam }
- name: Install base tools
ansible.builtin.apt:
name: [git, curl, htop, jq]
state: present
The second task needs no loop: apt accepts a list and installs everything in one transaction, which is much faster than a loop.
when runs a task only if a condition is true. It takes a raw Jinja2 expression, with no {{ }}:
- name: Install unattended-upgrades on Debian-family systems only
ansible.builtin.apt:
name: unattended-upgrades
state: present
when: ansible_facts['os_family'] == "Debian"
- name: Warn about low memory
ansible.builtin.debug:
msg: "{{ inventory_hostname }} has under 1 GB RAM"
when: ansible_facts['memtotal_mb'] < 1024
Roles¶
As playbooks grow, you split them into roles. A role is a directory with a fixed layout that Ansible understands. ansible-galaxy role init creates the skeleton:
roles/webapp/
├── defaults/main.yml # default variables (lowest precedence; meant to be overridden)
├── files/ # static files for copy
├── handlers/main.yml # handlers
├── meta/main.yml # metadata and role dependencies
├── tasks/main.yml # the task list (entry point)
├── templates/ # Jinja2 templates
├── tests/ # sample inventory and playbook
├── vars/main.yml # role variables (high precedence; not meant to be overridden)
└── README.md
Inside a role, copy: src=app.py looks in the role's files/ automatically, and template: src=x.j2 looks in its templates/. Delete the directories you don't use. A playbook then simply lists roles:
Roles are how you share and reuse automation. Ansible Galaxy (galaxy.ansible.com) hosts thousands of community roles and collections, installable with ansible-galaxy install and ansible-galaxy collection install. Read a community role's code before you run it as root on your servers.
Secrets with ansible-vault¶
Playbooks live in Git, so passwords and API tokens must not be stored in plain text. ansible-vault encrypts files (or single values) with AES-256 using a password. Ansible decrypts them in memory at run time.
The common layout keeps plain variables and secrets side by side, and makes the secrets visible by name:
group_vars/webservers/
├── vars.yml # plain: webapp_api_token: "{{ vault_webapp_api_token }}"
└── vault.yml # encrypted: vault_webapp_api_token: s3cr3t...
Create the encrypted file:
Your editor opens. Type the secret as normal YAML:
On disk it is now ciphertext, safe to commit:
$ANSIBLE_VAULT;1.1;AES256
62313365396662343061393464336163383764373764613633653634306231386433626264366566
6430366263323530316565653961643031346265636162350a646130613234663337346662306562
The vars.yml indirection (webapp_api_token: "{{ vault_webapp_api_token }}") means grep -r webapp_api_token still finds where a variable comes from, even though its value is encrypted.
Other vault commands:
ansible-vault view group_vars/webservers/vault.yml # decrypt to the screen
ansible-vault edit group_vars/webservers/vault.yml # decrypt, edit, re-encrypt
ansible-vault rekey group_vars/webservers/vault.yml # change the password
ansible-vault encrypt_string 'tok_9f8e7d6c5b4a' --name 'vault_webapp_api_token'
encrypt_string prints an inline encrypted value you can paste into any YAML file:
vault_webapp_api_token: !vault |
$ANSIBLE_VAULT;1.1;AES256
38613931633962353764353438613735663436363337613338613935356432616566383233
...
Encryption successful
Run playbooks with --ask-vault-pass, or point to a password file kept outside the repository: --vault-password-file ~/.ansible-vault-pass (with chmod 600). Add no_log: true to tasks that handle secrets, so values don't appear in output or logs.
Dry runs: --check and --diff¶
--check runs the playbook in check mode: each module reports what it would change, without changing anything. --diff shows the line-by-line difference for files. Together they answer "what will this playbook do to production?":
TASK [ssh_hardening : Deploy sshd hardening drop-in] ***************************
--- before: /etc/ssh/sshd_config.d/10-hardening.conf
+++ after: /home/alex/.ansible/tmp/ansible-local-51893x2k/tmpq1w8/sshd-hardening.conf.j2
@@ -2,5 +2,5 @@
PermitRootLogin no
PasswordAuthentication no
KbdInteractiveAuthentication no
-MaxAuthTries 6
+MaxAuthTries 3
AllowUsers alex
changed: [web1]
Someone changed MaxAuthTries by hand on the server. The playbook would put it back. Running --check --diff on a schedule is a simple drift detector.
Common mistake
Check mode is a simulation. Tasks that depend on an earlier change (for example, starting a service whose package is not yet installed) may fail or mislead in check mode, and command/shell tasks are skipped entirely. Treat --check as a strong hint, not a guarantee. Always do the real run against a VM before production.
ansible-lint¶
ansible-lint checks playbooks and roles for bugs, deprecated syntax, and bad practice. It is the shellcheck of Ansible. Install it with sudo apt install ansible-lint or pipx install ansible-lint, then run it in the project directory:
WARNING Listing 2 violation(s) that are fatal
name[missing]: All tasks should be named.
roles/webapp/tasks/main.yml:12 Task/Handler: ansible.builtin.copy src=app.py dest=/opt/webapp/app.py
no-changed-when: Commands should not change things if nothing needs doing.
site.yml:20 Task/Handler: Show uptime
Read documentation for instructions on how to ignore specific rule violations.
Rule Violation Summary
count tag profile rule associated tags
1 name[missing] basic idiom
1 no-changed-when shared command-shell, idempotency
Failed: 2 failure(s), 0 warning(s) on 14 files.
Fix each one. A task without a name makes output unreadable. A command without changed_when lies about changes. Run ansible-playbook site.yml --syntax-check for a quick parse check, and ansible-lint before every commit.
The capstone as code: rebuilding the Level 4 server¶
Now the payoff. This project rebuilds the Level 4 capstone server on a fresh Ubuntu 24.04 VM. Your own capstone may differ in details (app, ports, paths). Adapt the variables. It sets up:
- SSH hardening: key-only logins, no root login, limited users, with the config validated before it is applied.
- Firewall:
ufwwith default deny incoming, SSH rate-limited, and the app port open. - App user and app: a system user
webappand a small Python web app. - systemd service: the app as a sandboxed service that restarts on failure.
- Backups: a nightly
systemdtimer that archives the app's data and deletes old archives.
Layout:
capstone/
├── ansible.cfg
├── inventory.ini
├── site.yml
├── group_vars/
│ └── webservers/
│ ├── vars.yml
│ └── vault.yml # encrypted
└── roles/
├── ssh_hardening/
│ ├── handlers/main.yml
│ ├── tasks/main.yml
│ └── templates/sshd-hardening.conf.j2 (shown in the Jinja2 section)
├── firewall/
│ └── tasks/main.yml
├── webapp/
│ ├── files/app.py
│ ├── handlers/main.yml
│ ├── tasks/main.yml
│ └── templates/
│ ├── webapp.env.j2
│ └── webapp.service.j2
└── backup/
├── tasks/main.yml
└── templates/
├── backup-webapp.sh.j2
├── backup-webapp.service.j2
└── backup-webapp.timer.j2
ansible.cfg and inventory.ini are as shown earlier (the inventory needs only the [webservers] group). site.yml ties the roles together:
# site.yml
- name: Rebuild the Level 4 capstone server
hosts: webservers
become: true
pre_tasks:
- name: Update the apt cache and install base packages
ansible.builtin.apt:
name: [python3, ufw, tar, unattended-upgrades]
state: present
update_cache: true
cache_valid_time: 3600
roles:
- ssh_hardening
- firewall
- webapp
- backup
pre_tasks run before the roles. Roles run in the order listed.
Variables. group_vars/webservers/vars.yml:
admin_users: [alex]
ssh_max_auth_tries: 3
webapp_user: webapp
webapp_port: 8080
webapp_greeting: "Hello from the capstone server"
webapp_api_token: "{{ vault_webapp_api_token }}"
backup_dir: /var/backups/webapp
backup_keep_days: 14
backup_time: "*-*-* 02:30:00"
group_vars/webservers/vault.yml (create it with ansible-vault create):
Role ssh_hardening. roles/ssh_hardening/tasks/main.yml:
- name: Ensure admin users have their SSH keys
ansible.posix.authorized_key:
user: "{{ item }}"
key: "{{ lookup('ansible.builtin.file', '~/.ssh/id_ed25519.pub') }}"
loop: "{{ admin_users }}"
- name: Deploy sshd hardening drop-in
ansible.builtin.template:
src: sshd-hardening.conf.j2
dest: /etc/ssh/sshd_config.d/10-hardening.conf
owner: root
group: root
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Reload ssh
roles/ssh_hardening/handlers/main.yml:
The key task runs first, so you can't lock yourself out by disabling passwords before your key is in place. The lookup reads your public key on the control node. validate runs sshd -t on the rendered file (%s is its temporary path) before installing it. A syntax error fails the task instead of breaking SSH. The file is a drop-in in /etc/ssh/sshd_config.d/, which Ubuntu's main sshd_config includes at the top. For sshd, the first value it reads wins, so the low number 10- beats later files such as the cloud image's 60-cloudimg-settings.conf. Reloading keeps your current session alive.
Role firewall. roles/firewall/tasks/main.yml:
- name: Rate-limit SSH (allow it before enabling the firewall)
community.general.ufw:
rule: limit
port: "22"
proto: tcp
- name: Allow the web app port
community.general.ufw:
rule: allow
port: "{{ webapp_port | string }}"
proto: tcp
- name: Deny incoming by default and enable ufw
community.general.ufw:
state: enabled
direction: incoming
policy: deny
Order is the safety mechanism: the SSH rule exists before the firewall is switched on. rule: limit is ufw's built-in rate limit (more than 6 connections in 30 seconds from one address gets denied), as covered in Firewalls with ufw.
Role webapp. The app, roles/webapp/files/app.py, uses only the Python standard library:
#!/usr/bin/env python3
"""Tiny demo web app: counts hits in its state directory."""
import argparse
import os
from http.server import BaseHTTPRequestHandler, ThreadingHTTPServer
from pathlib import Path
STATE = Path(os.environ.get("STATE_DIRECTORY", "/var/lib/webapp")) / "hits.log"
GREETING = os.environ.get("WEBAPP_GREETING", "Hello")
class Handler(BaseHTTPRequestHandler):
def do_GET(self):
with STATE.open("a") as f:
f.write(self.client_address[0] + "\n")
with STATE.open() as f:
hits = sum(1 for _ in f)
body = f"{GREETING}! Hits so far: {hits}\n".encode()
self.send_response(200)
self.send_header("Content-Type", "text/plain")
self.send_header("Content-Length", str(len(body)))
self.end_headers()
self.wfile.write(body)
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--port", type=int, default=8000)
args = parser.parse_args()
ThreadingHTTPServer(("0.0.0.0", args.port), Handler).serve_forever()
roles/webapp/templates/webapp.env.j2, the environment file. It holds the secret, so only root may read it:
roles/webapp/templates/webapp.service.j2, the unit, with the sandboxing options from Your program as a service:
# Managed by Ansible.
[Unit]
Description=Capstone demo web app
After=network-online.target
Wants=network-online.target
[Service]
User={{ webapp_user }}
Group={{ webapp_user }}
EnvironmentFile=/etc/webapp/env
ExecStart=/usr/bin/python3 /opt/webapp/app.py --port {{ webapp_port }}
StateDirectory=webapp
Restart=on-failure
RestartSec=2
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
[Install]
WantedBy=multi-user.target
StateDirectory=webapp makes systemd create /var/lib/webapp, owned by the service user, and pass its path in $STATE_DIRECTORY. ProtectSystem=strict makes the rest of the filesystem read-only for the app.
roles/webapp/tasks/main.yml:
- name: Create the app's system user
ansible.builtin.user:
name: "{{ webapp_user }}"
system: true
shell: /usr/sbin/nologin
create_home: false
- name: Create app and config directories
ansible.builtin.file:
path: "{{ item.path }}"
state: directory
owner: root
group: root
mode: "{{ item.mode }}"
loop:
- { path: /opt/webapp, mode: "0755" }
- { path: /etc/webapp, mode: "0700" }
- name: Install the application code
ansible.builtin.copy:
src: app.py
dest: /opt/webapp/app.py
owner: root
group: root
mode: "0755"
notify: Restart webapp
- name: Install the environment file (contains a secret)
ansible.builtin.template:
src: webapp.env.j2
dest: /etc/webapp/env
owner: root
group: root
mode: "0600"
no_log: true
notify: Restart webapp
- name: Install the systemd unit
ansible.builtin.template:
src: webapp.service.j2
dest: /etc/systemd/system/webapp.service
mode: "0644"
notify: Restart webapp
- name: Enable and start the service
ansible.builtin.systemd_service:
name: webapp
state: started
enabled: true
daemon_reload: true
roles/webapp/handlers/main.yml:
- name: Restart webapp
ansible.builtin.systemd_service:
name: webapp
state: restarted
daemon_reload: true
The code is owned by root, so the webapp user can run it but not change it. The env file is readable only by root (0600). That is enough, because systemd reads EnvironmentFile= as root before it switches to the webapp user. The app receives the values as environment variables but cannot read the file itself. no_log: true keeps the token out of Ansible's output. Three tasks notify the same handler, but it runs once.
Role backup. roles/backup/templates/backup-webapp.sh.j2:
#!/usr/bin/env bash
# Managed by Ansible. Nightly backup of the web app's state.
set -euo pipefail
dest="{{ backup_dir }}"
stamp=$(date +%Y-%m-%d_%H%M%S)
tar -czf "$dest/webapp-$stamp.tar.gz" -C /var/lib webapp
find "$dest" -name 'webapp-*.tar.gz' -mtime +{{ backup_keep_days }} -delete
echo "backup written: $dest/webapp-$stamp.tar.gz"
roles/backup/templates/backup-webapp.service.j2:
# Managed by Ansible.
[Unit]
Description=Back up the web app's state
[Service]
Type=oneshot
ExecStart=/usr/local/sbin/backup-webapp
roles/backup/templates/backup-webapp.timer.j2:
# Managed by Ansible.
[Unit]
Description=Nightly web app backup
[Timer]
OnCalendar={{ backup_time }}
Persistent=true
RandomizedDelaySec=15m
[Install]
WantedBy=timers.target
roles/backup/tasks/main.yml:
- name: Create the backup directory
ansible.builtin.file:
path: "{{ backup_dir }}"
state: directory
owner: root
group: root
mode: "0700"
- name: Install the backup script
ansible.builtin.template:
src: backup-webapp.sh.j2
dest: /usr/local/sbin/backup-webapp
mode: "0750"
- name: Install the backup service and timer units
ansible.builtin.template:
src: "{{ item }}.j2"
dest: "/etc/systemd/system/{{ item }}"
mode: "0644"
loop:
- backup-webapp.service
- backup-webapp.timer
- name: Enable and start the timer
ansible.builtin.systemd_service:
name: backup-webapp.timer
state: started
enabled: true
daemon_reload: true
Only the timer is enabled. The service is started by the timer. Persistent=true runs a missed backup at the next boot if the server was off at 02:30. RandomizedDelaySec spreads the load when many servers share the schedule. These are the same choices as in Level 4, now written down forever.
Run it against a fresh VM:
cd ~/capstone
ansible-lint
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --ask-vault-pass
PLAY [Rebuild the Level 4 capstone server] *************************************
TASK [Gathering Facts] *********************************************************
ok: [web1]
TASK [Update the apt cache and install base packages] **************************
changed: [web1]
TASK [ssh_hardening : Ensure admin users have their SSH keys] ******************
ok: [web1] => (item=alex)
TASK [ssh_hardening : Deploy sshd hardening drop-in] ***************************
changed: [web1]
TASK [firewall : Rate-limit SSH (allow it before enabling the firewall)] *******
changed: [web1]
...
TASK [backup : Enable and start the timer] *************************************
changed: [web1]
RUNNING HANDLER [ssh_hardening : Reload ssh] ***********************************
changed: [web1]
RUNNING HANDLER [webapp : Restart webapp] **************************************
changed: [web1]
PLAY RECAP *********************************************************************
web1 : ok=19 changed=17 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Verify it the way you verified the capstone by hand, but across every server at once:
curl -s http://192.168.122.57:8080/
ansible webservers -b -a "ufw status"
ansible webservers -b -a "systemctl list-timers backup-webapp.timer --no-pager"
ansible webservers -b -a "sshd -T" | grep -E 'passwordauthentication|permitrootlogin|maxauthtries'
Hello from the capstone server! Hits so far: 1
...
passwordauthentication no
permitrootlogin no
maxauthtries 3
Run the playbook again and the recap must show changed=0. Then destroy the VM, create a fresh one, and run it again. You get the same server. That is the whole point.
Exercises¶
Exercise 1: Inventory and ad-hoc commands (easy)¶
⚠️ VM only (managed nodes). Write inventory.ini with your two lab VMs in a group lab, plus an ansible.cfg that points at it. Using only ad-hoc commands: ping both, show each host's kernel version, install tree on both with become, and show the free memory on web2 only.
Solution
ansible lab -m ping
ansible lab -a "uname -r"
ansible lab -b -m apt -a "name=tree state=present update_cache=true"
ansible web2 -a "free -h"
The kernel version could also come from facts: ansible lab -m setup -a "filter=ansible_kernel". Run the apt command twice: the second run reports SUCCESS and "changed": false.
Exercise 2: Make a script idempotent (easy)¶
⚠️ VM only. This shell snippet is run on every server after provisioning. Rewrite it as a playbook that reports changed=0 on its second run:
apt-get install -y chrony
echo "Europe/Berlin" > /etc/timezone
echo "alias ll='ls -alF'" >> /etc/bash.bashrc
mkdir /srv/data
chmod 775 /srv/data
Solution
- name: Post-provisioning basics
hosts: lab
become: true
tasks:
- name: Install chrony
ansible.builtin.apt:
name: chrony
state: present
update_cache: true
cache_valid_time: 3600
- name: Set the timezone
community.general.timezone:
name: Europe/Berlin
- name: Add the ll alias once
ansible.builtin.lineinfile:
path: /etc/bash.bashrc
regexp: "^alias ll="
line: "alias ll='ls -alF'"
- name: Create /srv/data
ansible.builtin.file:
path: /srv/data
state: directory
mode: "0775"
The original fails on its second run (mkdir errors because the directory exists) and appends duplicate aliases. On Ubuntu, writing /etc/timezone alone also doesn't change the active timezone. The timezone module does it properly through timedatectl. Each module checks the current state first, so the second run reports changed=0.
Exercise 3: A template with a loop and a handler (medium)¶
⚠️ VM only. Install nginx on both VMs and deploy /var/www/html/index.html from a Jinja2 template. It must show the host's inventory name, its OS version from facts, and an HTML list of team members from a variable team: [alex, sam, priya] defined in group_vars/lab.yml. Also deploy /etc/nginx/conf.d/status.conf with a server_tokens off; line, which must reload nginx through a handler only when it changes. Prove the handler doesn't fire on the second run.
Solution
templates/index.html.j2:
<h1>{{ inventory_hostname }}</h1>
<p>Running {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}</p>
<ul>
{% for person in team %}
<li>{{ person }}</li>
{% endfor %}
</ul>
web.yml:
- name: Web servers with a templated page
hosts: lab
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy the home page
ansible.builtin.template:
src: templates/index.html.j2
dest: /var/www/html/index.html
mode: "0644"
- name: Hide the nginx version
ansible.builtin.copy:
content: "server_tokens off;\n"
dest: /etc/nginx/conf.d/status.conf
mode: "0644"
notify: Reload nginx
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
The first run shows RUNNING HANDLER [Reload nginx]. The second run shows no handler section and changed=0. Check with curl -sI http://192.168.122.57/ | grep Server, which prints Server: nginx without a version number. The page itself doesn't need a reload: nginx serves files from disk on every request.
Exercise 4: Vault and a role (medium)¶
⚠️ VM only. Turn Exercise 3 into a role website (with ansible-galaxy role init), moving tasks, handlers, and templates into it. Add an encrypted variable vault_admin_email, exposed as admin_email in group_vars/lab/vars.yml, and show it in the page footer. Run with --ask-vault-pass and confirm that git grep for the email address finds nothing in the repository.
Solution
ansible-galaxy role init --init-path roles website
mv templates/index.html.j2 roles/website/templates/
mkdir -p group_vars/lab
mv group_vars/lab.yml group_vars/lab/vars.yml
echo 'admin_email: "{{ vault_admin_email }}"' >> group_vars/lab/vars.yml
ansible-vault create group_vars/lab/vault.yml # vault_admin_email: "ops@example.com"
Move the three tasks into roles/website/tasks/main.yml and the handler into roles/website/handlers/main.yml. Change the template src to index.html.j2, because role paths are relative to the role's templates/. Add <footer>Contact: {{ admin_email }}</footer> to the template. The playbook becomes:
ansible-playbook web.yml --ask-vault-pass
git add -A && git grep -n "ops@example.com" || echo "not found in plain text"
The address exists only inside the encrypted vault.yml (and on the rendered page on the servers).
Exercise 5: Prove the capstone is reproducible (hard)¶
⚠️ VM only. Build the full capstone project from this chapter. Then: (1) run it on a fresh VM and confirm every Level 4 requirement; (2) run it again and get changed=0; (3) on the server, by hand, set MaxAuthTries 6 in the drop-in and run sudo ufw allow 3306; (4) detect both changes with --check --diff; (5) explain why the ufw change is not detected, and change the firewall role so that it would be.
Solution
Steps (1) to (3) follow the chapter. For step (4), ansible-playbook site.yml --check --diff --ask-vault-pass shows a diff for 10-hardening.conf (-MaxAuthTries 6 / +MaxAuthTries 3) and reports it as changed.
(5) The extra ufw rule is not reported. The ufw module tasks only ensure that the rules listed exist. They say nothing about rules that should not exist. This is a general property of declarative tools: they manage what you declare, not everything on the machine.
One fix is to reset ufw to a known state first, then add the wanted rules. For example, add this task at the start of the firewall role:
- name: Detect unexpected ufw rules
ansible.builtin.command: ufw show added
register: ufw_added
changed_when: false
check_mode: false
- name: Fail if rules exist that we did not declare
ansible.builtin.assert:
that: "'3306' not in ufw_added.stdout"
fail_msg: "Unmanaged ufw rule found: {{ ufw_added.stdout }}"
A more complete approach is community.general.ufw: state=reset followed by all declared rules, accepting that this briefly re-applies the firewall on each run. Another is to manage the firewall rules file itself as a template. Real teams choose based on how strict they need drift detection to be.
Check yourself¶
-
What does idempotent mean, and why is it the most important property of configuration management?
Answer
An idempotent operation gives the same result whether it runs once or many times. It makes automation safe to re-run: applying a playbook to an already-correct server changes nothing, and applying it to a drifted server fixes only the drift. Without it, re-runs would duplicate lines, fail on existing objects, or restart services needlessly, so you couldn't run your automation routinely.
-
What does "agentless" mean for Ansible, and what must a managed node have?
Answer
Nothing Ansible-specific is installed or kept running on managed nodes. The control node connects over SSH, copies small module programs to a temporary directory, runs them, and deletes them. A managed node needs an SSH server you can log in to (normally with a key), Python 3, and
sudorights for tasks that usebecome. -
What is the difference between
okandchangedin Ansible output, and what should a second run of a good playbook show?Answer
okmeans the resource was already in the desired state, so nothing was done.changedmeans the module modified something to reach the desired state. A second run of a well-written, idempotent playbook should reportchanged=0for every host. -
When does a handler run, and why use one instead of a normal task that restarts the service?
Answer
A handler runs only if a task that notifies it reports
changed, and then only once, at the end of the play (or when handlers are flushed), however many tasks notified it. A normal restart task would restart the service on every run, causing needless interruptions, and it couldn't merge several config changes into one restart. -
Where do
host_vars/web1.ymlandgroup_vars/webservers.ymllive, and which wins if both set the same variable?Answer
Both directories sit next to the inventory file (or the playbook). Ansible loads them automatically for hosts and groups with matching names. Host variables are more specific, so
host_vars/web1.ymlwins overgroup_vars/webservers.ymlforweb1. Extra vars on the command line (-e) beat both. -
How do you keep a database password in a Git repository safely with Ansible, and how do you keep it out of the run output?
Answer
Encrypt it with
ansible-vault, either a whole file (for examplegroup_vars/webservers/vault.ymlwithvault_db_password: ...) or a single value withansible-vault encrypt_string. Reference it from a plain variable (db_password: "{{ vault_db_password }}"). Provide the vault password at run time with--ask-vault-passor a password file kept outside the repository. Addno_log: trueto tasks that use the secret so it doesn't appear in output or logs. -
What do
--checkand--diffdo, and what is one limitation of check mode?Answer
--checkruns modules in simulation mode: they report what they would change without changing anything.--diffshows line-by-line differences for files and templates. Limitation: tasks that depend on earlier changes can fail or mislead (for example, a service from a package not yet installed), andcommand/shelltasks are skipped unless markedcheck_mode: false. So check mode is a preview, not a guarantee. -
How do Ansible, cloud-init, and Terraform divide the work in a typical setup?
Answer
Terraform (or OpenTofu) provisions the infrastructure through cloud APIs: it creates VMs, networks, disks, and DNS records. cloud-init runs once inside each new VM at first boot, to create a user, install SSH keys, and set the hostname. Ansible then configures the running machines (packages, files, services, users, firewalls) and keeps them in the desired state on later runs.
Key takeaways¶
- Infrastructure as code puts server configuration in version-controlled files. Rebuilds become repeatable, reviewable, and fast, and configuration drift becomes visible.
- Idempotency (describe the desired state, change only what differs) makes automation safe to re-run. A second run should report
changed=0. - Ansible is agentless: a control node pushes small Python modules over SSH to managed nodes listed in an inventory, with
becomefor root. - Playbooks hold plays of named tasks that use modules (
apt,copy,template,service,user,file,lineinfile,ufw). Handlers react to changes, and Jinja2 templates, variables, facts, loops, andwhenmake them flexible. - Roles package reusable automation, ansible-vault keeps secrets encrypted in Git, and
--check --diffplusansible-lintcatch problems before they reach servers. - Order tasks for safety: install the SSH key before disabling passwords, allow SSH before enabling the firewall, and validate configs before applying them.
- Ansible configures machines. cloud-init bootstraps them, Terraform provisions them, and NixOS takes the declarative idea to the whole OS.
Next¶
You have reached the end of the Level 6 chapters. Put it all together in the Level 6 capstone.