How to use this page
Find the job you're trying to do, and copy the pattern. Words in capitals, like HOST, GROUP or FILE, are yours to replace. Every row links to the lesson that explains it, with real output from the practice servers. Use the print button at the top to keep a copy next to you.
Three habits that make everything here easier:
- Dry run first.
ansible-playbook site.yml --check --diffshows what would change, and changes nothing. A failure in--checkthat depends on an earlier change is expected: the earlier change never really happened. - Read the recap, not the scroll. The last lines say, for every server, how many tasks were
ok,changedorfailed. On a second run,changed=0is what healthy looks like. - Change the files, not the servers. A fix made by hand on a server lasts until the next rebuild. Put it in a playbook, and it comes back every time.
When Ansible says no
Start here when something fails. Match the message, then look where it says.
| The message says | It means | Look at | Lesson |
|---|---|---|---|
UNREACHABLE! | Ansible couldn't connect over SSH: the server is off, gone, or has a new address | vagrant status, then vagrant ssh-config > ssh_config | 1, 11 |
An error about temporary files, with --become-user | Becoming a user other than root needs the acl package on the server | Install acl first | 2 |
'NAME' is undefined | Nothing this playbook can see sets that variable. A role's defaults only exist in plays that use the role | ansible-inventory --host HOST, then put shared values in group_vars | 7 |
'role' is not a valid attribute for a Play | A mistyped keyword: here, role: for roles: | ansible-playbook FILE --syntax-check | 10 |
Attempting to decrypt but no vault secrets found | A vault file needs opening, and Ansible has no vault password | .vault_pass, and vault_password_file in ansible.cfg | 8 |
Decryption failed (no vault secrets were found that could decrypt) | It has a vault password, but the wrong one: often after a rekey | Which password the file was last encrypted with | 8 |
src file does not exist, only in --check | The task depends on a file an earlier task would have written, and in a dry run didn't | Nothing: read the diffs before it, then run for real | 5 |
FAILED! with "rc": 2 from a command | The command exited non-zero. Sometimes that's the answer you wanted: getent gives 2 for "not found" | What that command's exit codes mean | 6 |
Status code was -1 ... Connection refused | The server answered, but nothing is listening on that port: the service is stopped, or failed to start | systemctl status, and the service's config test | 6, 9 |
unknown directive or invalid port from nginx -t | nginx's config is broken. A running nginx keeps its last good config until a reload or reboot | The file and line number in the message | 5, 6 |
FAILED - RETRYING ... (N retries left) | A task with until: is waiting for something. Not an error yet | Only the fatal after the last retry | 6 |
NO MORE HOSTS LEFT | Every server in the play, or the batch, failed, so the run stopped | The first fatal above it | 9 |
[WARNING]: Module remote_tmp ... did not exist | Ansible made a temporary folder for another user. Harmless | Nothing | 8, 12 |
couldn't resolve module/action 'community.general.ufw' | The module comes from a collection that isn't installed | ansible-galaxy collection install -r requirements.yml | 12 |
Reading a recap
Every playbook ends with a PLAY RECAP: one line per server.
| Count | Means | Lesson |
|---|---|---|
ok | Tasks that ran and found nothing to change, plus those that changed something | 3 |
changed | Tasks that changed the server. On a second run, it should be 0 | 3 |
failed | Tasks that failed. The server drops out of the rest of the play | 6 |
unreachable | Ansible couldn't connect at all | 11 |
skipped | Tasks whose when: was false, or that don't run in --check | 6 |
rescued | Failures caught by a rescue:. They don't count as failed, so they don't fail the run | 6 |
ignored | Failures on tasks with ignore_errors: true | 6 |
Reaching the servers
| To | Run | Lesson |
|---|---|---|
| Install Ansible (Mac) | brew install ansible | 1 |
| Start, stop the practice servers | vagrant up, vagrant halt | 1 |
| Tell SSH, and Ansible, how to reach them | vagrant ssh-config > ssh_config, after every new VM | 1, 11 |
| Check Ansible can reach them | ansible all -m ping -o | 1 |
| Delete one VM, and make it again | vagrant destroy -f web2, vagrant up web2 | 11 |
| Point Ansible at an SSH config file | ssh_args = -F ssh_config, in ansible.cfg | 1 |
| Stop the Python interpreter warnings | interpreter_python = auto_silent, in ansible.cfg | 1 |
Inventory and ad-hoc commands
| To | Run | Lesson |
|---|---|---|
| See the inventory as a tree | ansible-inventory --graph, with --vars for settings | 2 |
| One server's settings, from the inventory | ansible-inventory --host HOST | 2, 7 |
| Which servers a pattern picks | ansible "all:!db" --list-hosts | 2 |
| Either group, both, all but | web:db, bookshop:&web, all:!db | 2 |
| Run a command everywhere | ansible all -m command -a "uptime -p" | 1 |
| Pipes and redirects | ansible all -m shell -a "CMD | CMD" | 2 |
| Run as root | --become | 2 |
| Run as another user | --become --become-user USER, which needs acl | 2 |
| Make sure a package is installed | ansible web -m apt -a "name=tree state=present" --become | 1 |
| Print a variable on each server | ansible web -m debug -a "var=http_port" | 2 |
| See a server's facts | ansible HOST -m setup -a "filter=ansible_default_ipv4" | 4 |
| Run a script from your computer on a server | ansible HOST -m script -a audit.sh --become | 12 |
| Find a module, read its options | ansible-doc -l, ansible-doc ansible.builtin.user | 2 |
Running playbooks
| To | Run | Lesson |
|---|---|---|
| Check it parses, without servers | ansible-playbook site.yml --syntax-check | 3, 10 |
| See which servers, and which tasks | --list-hosts, --list-tasks | 3 |
| Dry run, with file changes shown | ansible-playbook site.yml --check --diff | 3 |
| Run it | ansible-playbook site.yml | 3 |
| Run it on some servers only | --limit web1, or --limit "web1,db1" | 3 |
| Change a variable for one run | -e "http_port=9999" | 4 |
| Run handlers even after a failure | --force-handlers | 5 |
| Time a run | time ansible-playbook site.yml | 6 |
The shape of a playbook, from Lesson 3:
- name: Web servers # a play: which servers, and what to do
hosts: web
become: true
tasks:
- name: Install nginx # a task: one module, with its options
ansible.builtin.apt:
name: nginx
state: present
Variables and facts
| To | Write | Lesson |
|---|---|---|
| Settings for every server | group_vars/all.yml | 4 |
| Settings for one group | group_vars/web.yml, or files in group_vars/web/ | 4, 8 |
| Settings for one server | host_vars/db1.yml, or web2 http_port=8080 in the inventory | 4 |
| Use a value | {{ http_port }}, quoted when it starts a line: "{{ ... }}" | 4 |
| A fallback value | {{ http_port | default(80) }} | 4 |
| Leave a setting out if there's no value | {{ item.comment | default(omit) }} | 6 |
| Use a fact | {{ ansible_facts['default_ipv4']['address'] }} | 4 |
| Read another server's variable | {{ hostvars['db1']['db_port'] }} | 4, 8 |
| Save a task's result | register: result, then result.stdout, result.rc | 4 |
| Work out a new variable | ansible.builtin.set_fact: | 4 |
| Print a variable while running | ansible.builtin.debug: with var: or msg: | 4 |
| Skip gathering facts | gather_facts: false, on the play | 4 |
| Turn text back into a number | {{ port | int }} | 10 |
Which value wins, simplified, from weakest to strongest: a role's defaults, then group_vars/all, then group_vars/GROUP, then the server's own variables, then a play's vars:, then a role's vars, then register and set_fact, and strongest of all, -e. Two rules from the track: a value more than one playbook needs goes in the inventory (Lesson 7), and settings people may change go in a role's defaults, never its vars.
Templates and handlers
| To | Write | Lesson |
|---|---|---|
| Write a file from a template | ansible.builtin.template with src, dest, mode | 5 |
| Put in a value | {{ http_port }} | 5 |
| Include a line only sometimes | {% if http_port != 80 %} ... {% endif %} | 5 |
| Say a file is managed | {{ ansible_managed }}, in a comment | 5 |
| Test a new file before it's put in place | validate: visudo -cf %s, or nginx -t -c %s for a whole config | 5, 12 |
| Restart or reload only after a change | notify: Reload nginx on the task, and a handler with that name | 5 |
| Reread config, keeping connections | state: reloaded, rather than restarted | 5 |
| Check nginx's config, changing nothing | ansible.builtin.command: nginx -t, with changed_when: false | 5 |
| Run handlers now, mid-play | - ansible.builtin.meta: flush_handlers | 5 |
Loops, conditions and failures
| To | Write | Lesson |
|---|---|---|
| Run a task once per item | loop: "{{ LIST }}", then item inside it | 6 |
| Print only part of each item | loop_control: with label: "{{ item.name }}" | 6 |
| Remove something | state: absent, not deleting its line | 6 |
| Run a task only sometimes | when: result.stat.exists, with no {{ }} | 6 |
| Look at a file without changing it | ansible.builtin.stat, then .stat.exists | 6 |
| Retry until something is true | until:, retries: 5, delay: 2 | 6 |
| Fail unless a condition holds | ansible.builtin.assert with that: | 6 |
| Decide what failure means | failed_when: CONDITION | 6 |
| Decide what a change means | changed_when: CONDITION, or false for checks | 4, 12 |
| A shell task that runs only once | creates: /path/it/makes | 3 |
| Try, recover, and finish | block:, rescue:, always: | 6 |
| Fail on purpose, with a message | ansible.builtin.fail with msg, at the end of a rescue | 9 |
| Make a web request | ansible.builtin.uri with url, return_content: true | 6 |
changed_when: false only stops a task saying it changed something. It doesn't stop the change. Use it for checks that really change nothing, like nginx -t.
Roles
| To | Write or run | Lesson |
|---|---|---|
| Make an empty role | ansible-galaxy role init --init-path roles NAME | 7 |
| A role's tasks, the only part it must have | roles/NAME/tasks/main.yml | 7 |
| Its handlers | roles/NAME/handlers/main.yml | 7 |
| Its templates and files, used without a path | roles/NAME/templates/, roles/NAME/files/ | 7 |
| Settings anyone can override | roles/NAME/defaults/main.yml | 7 |
| Settings the inventory can't override | roles/NAME/vars/main.yml, rarely | 7 |
| Use roles in a play | roles:, then - NAME | 7 |
| Name a role's variables safely | The role's name first: nginx_http_port, health_page | 10 |
| Let the inventory keep its own names | nginx_http_port: "{{ http_port | default(80) }}", in defaults | 10 |
| Prove a refactor changed nothing | --check --diff, then a real run: changed=0 | 7 |
Secrets with Vault
| To | Run or write | Lesson |
|---|---|---|
| Keep the vault password out of git | .vault_pass, mode 600, in .gitignore; check with git check-ignore -v .vault_pass | 8 |
| Use it every run | vault_password_file = .vault_pass, in ansible.cfg | 8 |
| Name the secret where people can search | db_password: "{{ vault_db_password }}" in vars.yml, the value in vault.yml | 8 |
| Encrypt, read, change a file | ansible-vault encrypt FILE, view FILE, edit FILE | 8 |
| Start a new encrypted file | ansible-vault create FILE | 8 |
| Encrypt one value for YAML | ansible-vault encrypt_string "VALUE" --name NAME | 8 |
| Change the vault password | ansible-vault rekey --new-vault-password-file NEW FILE, then change the secrets too | 8 |
| Keep a secret out of the output | no_log: true, on every task that uses it | 8 |
| Give a GitHub workflow the password | gh secret set VAULT_PASSWORD, then secrets.VAULT_PASSWORD | 10 |
Patching a fleet
| To | Write or run | Lesson |
|---|---|---|
| Run a play one server at a time | serial: 1, on the play | 9 |
| Start small, then grow the batches | serial: [1, 5, "25%"] | 9 |
| Stop at the first failure in a batch | max_fail_percentage: 0 | 9 |
| Stop the whole run at the first failure | any_errors_fatal: true | 6 |
| Refresh the package list | ansible.builtin.apt with update_cache: true | 9 |
| Upgrade packages, cautiously | ansible.builtin.apt with upgrade: safe | 9 |
| Reboot only if Ubuntu asks | stat on /var/run/reboot-required, then ansible.builtin.reboot | 9 |
| Count waiting updates | apt list --upgradable. On a new server, refresh the list first: 0 can mean "never checked" | 9, 11 |
| Patch the whole bookshop | ansible-playbook patch.yml | 9 |
Testing
| To | Run | Lesson |
|---|---|---|
| Install the linter | brew install ansible-lint, or pipx install ansible-lint | 10 |
| Lint the whole repository | ansible-lint; -f pep8 for one finding per line | 10 |
| Skip a rule on one line, with a reason | # noqa: RULE, and a comment saying why | 10, 12 |
| Test idempotence | Run twice: the second recap must show changed=0 | 10 |
| Run every check | ./test.sh | 10 |
| Check the health of the web servers | ansible-playbook health.yml | 6 |
| Wait for a pull request's checks | gh pr checks --watch | 10 |
The lint rules the track met, and what each wants:
| Rule | Wants | Lesson |
|---|---|---|
var-naming[no-role-prefix] | A role's variables to start with the role's name | 10 |
no-changed-when | changed_when, creates or removes on every command and shell task | 10 |
partial-become | become: true next to every become_user | 12 |
command-instead-of-module | A module, where one exists, instead of running its command | 12 |
Rebuilding a server
vagrant destroy -f web2 # gone, disk and all
vagrant up web2 # a new, empty server
vagrant ssh-config > ssh_config # its new key, and maybe a new address
ansible-playbook site.yml --limit web2 # describe it
ansible-playbook patch.yml --limit web2 # bring it up to date
ansible-playbook health.yml && ./test.sh # prove it
About three minutes in Lesson 11. Everything in the playbooks comes back; anything done by hand doesn't.
Collections, and Ansible for each bash habit
ansible-galaxy collection install -r requirements.yml
ansible-galaxy collection list
| In bash | In Ansible | Lesson |
|---|---|---|
apt-get install -y PKG | ansible.builtin.apt with name: | 3 |
id NAME || useradd ... | ansible.builtin.user | 3 |
Compare, then install -m MODE | ansible.builtin.copy with mode, owner, group | 12 |
grep -q LINE FILE || echo LINE >> FILE | ansible.builtin.lineinfile | 12 |
ufw allow ... | community.general.ufw | 12 |
Check fstab, append, mount | ansible.posix.mount with state: mounted | 12 |
createdb, psql -c | community.postgresql.postgresql_db, postgresql_table, postgresql_query | 12 |
runuser -u postgres -- | become: true and become_user: postgres | 12 |
A "changed" flag, then systemctl restart | notify: and a handler | 5, 12 |
systemctl enable --now | ansible.builtin.systemd_service with enabled: true, state: started | 12 |
set -euo pipefail, and care | Nothing: a failed task stops the run | 12 |