Your first playbook
Plays, tasks, and YAML structure.
An ad-hoc command (a one-off instruction you type at the shell and fire at your servers) is you leaning out of the door and shouting across the server room. It works. It also lives nowhere, gets remembered wrong, and walks out of the building with whoever shouted it. A playbook is the written runbook you hand a new hire: same steps, same order, nobody improvising at 2am. It is a plain text file written in YAML (YAML Ain't Markup Language, a format built to be easy for people to read and easy for machines to parse) that describes the state you want your machines to be in. Ansible reads it from the top and makes reality match.
The payoff goes well past tidiness. Once the file exists, the knowledge sits in your repository instead of in one engineer's head, so it goes through code review, carries a commit history, and answers the question every incident eventually asks: who changed this box, when, and to what? A shouted command answers none of that. A playbook is also the thing you can point at forty hosts at once, which is why most of this lesson is about seeing what it will do before it does it.
A Play Maps Hosts To Work
Open any playbook and the outermost thing in the file is a list of plays. A play is one order ticket in a kitchen: this table, these dishes, in this sequence. It answers a single question. Which machines, and what should happen on them? Three keys carry nearly all the weight. hosts is a pattern that picks machines out of your inventory (the file that lists which servers exist and which groups they belong to). become says whether the work runs with escalated privileges, by default through sudo (superuser do, the standard Linux way of running a command as another user, usually root). tasks is the ordered list of things to do.
The shape is ordinary YAML, and YAML has two moves you need here. A leading dash starts a new item in a list. A key, a colon, a space, then a value makes one entry in a mapping. Indentation, spaces only, decides what belongs to what. The three dashes on the first line mark the start of a YAML document. That is a convention rather than a requirement, though every playbook you meet in the wild will have it.
---- name: Configure web servers # the play label, printed when it runshosts: web # a group from your inventory, not one hostnamebecome: true # every task below runs as root, via sudotasks:- name: Install nginxansible.builtin.package: # the modulename: nginx # the module's arguments, indented under itstate: present- name: Start nginx now and on every bootansible.builtin.service:name: nginxstate: startedenabled: true
Read one task closely, because every task you will ever write has the same three parts. A name, which is free text. Exactly one module call, where a module is the small program Ansible copies to the far end and runs to do the actual work. And underneath that module, indented, its arguments. The name is technically optional. Write one anyway. It is what prints on screen during the run and what you scan when something breaks at 2am, and a bare TASK [ansible.builtin.package] banner tells you nothing about intent.
ansible.builtin.package looks long-winded next to plain package. The long form is an FQCN (fully qualified collection name: namespace, then collection, then module). Treat it as the full postal address rather than writing "Dave" on the envelope and hoping the sorting office knows which Dave you mean. Ansible ships thousands of modules spread across separate collections, and nothing stops two collections shipping a module under the same short name. Write the short name and the meaning of your file depends on which collections happen to be installed on the control node the day it runs. Write the FQCN and the file means one thing forever. package is also the portable choice, handing off to apt on Debian and dnf on Red Hat systems; reach for ansible.builtin.apt only when you need an option that only apt has.
Prove It Parses Before It Touches Anything
Two commands run without opening a single SSH (Secure Shell, the encrypted remote-login protocol Ansible rides on) connection, and both belong in your fingers. The first is --syntax-check. It reads your file the way a proofreader reads a recipe: is this legible, does the structure hold, are the steps actually steps? It takes about a second, and it catches the broken indent that would otherwise blow up after three tasks had already changed things.
$ ansible-playbook -i inventory.ini site.yml --syntax-check
playbook: site.yml
That one line is the whole success message. Quiet means clean. Now the limits, because people trust this flag further than it deserves. It does resolve every module name against the collections installed on your control node, so a fat-fingered ansible.builtin.pakage stops right here with ERROR! couldn't resolve module/action 'ansible.builtin.pakage'. What it never does is look inside a task's arguments. Hand ansible.builtin.service an option it has never heard of and syntax-check waves it through; the module itself rejects it, and only once the task actually runs on a host. It also cannot tell you whether the group named in hosts: exists, whether a variable you reference is defined, or whether a single machine out there is reachable.
The second cheap command answers the blast radius question. It is the guest list you check before you start cooking. --list-hosts expands your host patterns against the inventory and prints the exact machines a run would touch.
$ ansible-playbook -i inventory.ini site.yml --list-hosts
playbook: site.ymlplay #1 (web): Configure web servers TAGS: []pattern: ['web']hosts (2):web01.acme.internalweb02.acme.internal
Two hosts. If that list ever surprises you, stop, because the group named in hosts: is the only thing standing between your change and every machine in the inventory. For a single narrower run, --limit web01.acme.internal intersects with the play's pattern and shrinks it. Note the direction. --limit can only take hosts away, never pull in a machine the play did not select, which is the safe way round for a flag you type in a hurry. Its sibling --list-tasks prints the task order of each play, also without touching anything.
Check Mode Shows You The Change Before It Lands
Check mode is reading the recipe aloud and pointing at each ingredient with the stove off. Add --check and Ansible still connects to the hosts and still asks each module what it would do, then reports the answer and writes nothing. Add --diff on top and it prints the exact before and after lines for any file it would edit. Now append a task that decides who is allowed to log in as root, and the gap between predicted and applied becomes the gap between a change window and an incident.
# a third task, appended to the tasks: list above- name: Disable direct root login over SSHansible.builtin.lineinfile:path: /etc/ssh/sshd_configregexp: '^#?PermitRootLogin' # matches the commented default tooline: "PermitRootLogin no"validate: /usr/sbin/sshd -t -f %s # refuse to save a file sshd rejects
$ ansible-playbook -i inventory.ini site.yml --check --diff --limit web01.acme.internal
PLAY [Configure web servers] ***************************************************TASK [Gathering Facts] *********************************************************ok: [web01.acme.internal]TASK [Install nginx] ***********************************************************ok: [web01.acme.internal]TASK [Start nginx now and on every boot] ***************************************ok: [web01.acme.internal]TASK [Disable direct root login over SSH] **************************************--- before: /etc/ssh/sshd_config (content)+++ after: /etc/ssh/sshd_config (content)@@ -30,7 +30,7 @@# Authentication:#LoginGraceTime 2m-#PermitRootLogin prohibit-password+PermitRootLogin no#StrictModes yes#MaxAuthTries 6#MaxSessions 10changed: [web01.acme.internal]PLAY RECAP *********************************************************************web01.acme.internal : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
These are existing web servers, so the first two tasks report ok and only the new one wants to change anything. You get to review a security change as a diff before a single byte is written. The regexp matches the commented-out default as well as an already-set line, so the task behaves correctly against a host in either state, and the diff proves it. The validate line is the seatbelt: Ansible writes the candidate file to a temporary path, runs /usr/sbin/sshd -t -f against that path, and only moves it into place if sshd approves. The %s is where Ansible substitutes that temp path, and leaving it out is an error rather than a silent skip. Without validate, one typo plus a service restart equals a box you can no longer log into.
Two things this task does not do, both worth knowing before you ship it. Editing the file changes the file and nothing else, so sshd keeps serving its old configuration until the service reloads, which normally means notifying a handler from this task. And on current Debian and Ubuntu images the shipped sshd_config opens with Include /etc/ssh/sshd_config.d/*.conf, while OpenSSH keeps the first value it sees for any keyword. A cloud image that drops PermitRootLogin prohibit-password into 50-cloud-init.conf therefore beats the line you wrote further down the main file. Your diff looks perfect and root logins stay enabled. Confirm with sshd -T on the host, which prints the configuration as sshd actually resolved it.
Reading The Run
Drop the flags and it is real.
$ ansible-playbook -i inventory.ini site.yml
PLAY [Configure web servers] ***************************************************TASK [Gathering Facts] *********************************************************ok: [web01.acme.internal]ok: [web02.acme.internal]TASK [Install nginx] ***********************************************************ok: [web01.acme.internal]ok: [web02.acme.internal]TASK [Start nginx now and on every boot] ***************************************ok: [web01.acme.internal]ok: [web02.acme.internal]TASK [Disable direct root login over SSH] **************************************changed: [web01.acme.internal]changed: [web02.acme.internal]PLAY RECAP *********************************************************************web01.acme.internal : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0web02.acme.internal : ok=4 changed=1 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Check mode predicted one change and the run made one change. Notice something else. Every play opens with Gathering Facts whether you asked for it or not. That is ansible.builtin.setup collecting facts about each host (operating system family, IP addresses, memory, mounted disks, hundreds of them) so your later tasks and templates can read them. It costs one connection per host and it counts as one ok in the tally. You wrote three tasks and the recap says ok=4. That is the missing one.
The word in front of each host is its status. ok means the task ran and the machine already matched what you declared, so nothing was touched. changed means Ansible altered something. The ok column counts every successful task, changed ones included, which is why ok=4 and changed=1 are describing the same four tasks rather than five. failed means the module returned an error, and that host is quietly dropped from the rest of the play while the others carry on. The recap then adds columns people ignore until the day they need them. unreachable counts hosts Ansible could not connect to at all (SSH refused, host down, wrong key), a completely different problem from failed. skipped counts tasks a when: condition ruled out. rescued and ignored come from block/rescue error handling and from ignore_errors.
Now run exactly the same command again, and read the tail of the output.
$ ansible-playbook -i inventory.ini site.yml
TASK [Disable direct root login over SSH] **************************************ok: [web01.acme.internal]ok: [web02.acme.internal]PLAY RECAP *********************************************************************web01.acme.internal : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0web02.acme.internal : ok=4 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 is the proof. Each module inspected the host, found it already in the declared state, and did nothing. A thermostat behaves the same way: ask for 20 degrees in a room that is already at 20 degrees and the boiler stays off. The property has a name, idempotent (running it a second time changes nothing the second time), and it quietly turns your playbook into a detector. Schedule the same file in check mode against production every night and any non-zero changed count is a machine that has drifted away from the file: somebody hand-edited sshd_config during an incident, a package got downgraded, a service was left stopped after a debugging session. Configuration nobody authorized shows up as a yellow line in a nightly report instead of as a finding in next year's audit.
Tasks Walk Across Hosts, Not The Other Way Round
This is the part that catches people, and it catches them expensively. Ansible does not send one host marching through the whole playbook alone. Under the default strategy, named linear, it runs task 1 on every host in the play, waits for the slowest one to finish, then runs task 2 on every host, and so on down the file. The kitchen plates the starter for the entire table before anyone's main course begins.
A second dial sits next to it. forks controls how many hosts the control node talks to at the same moment, and the default is 5. It is the number of waiters on the floor, not the running order. With forty hosts and the default forks, task 1 sweeps through in batches of five, but all forty finish task 1 before task 2 starts anywhere.
serial changes the shape completely. Put it on the play and Ansible cuts the host list into batches, then runs the entire play, every task in it, on batch one before it touches batch two. serial: 1 gives you one host at a time. A list like serial: [1, 5, '25%'] gives you a canary, one host sent in first the way miners sent a bird down the shaft, then five, then a quarter of what is left each round. A bad change stops after one casualty rather than forty.
---- name: Roll the new nginx config out one host at a timehosts: webbecome: trueserial: 1 # whole play on host 1, then host 2, then host 3max_fail_percentage: 0 # any failure in a batch stops the run before the nexttasks:- name: Ship the configansible.builtin.template:src: nginx.conf.j2dest: /etc/nginx/nginx.confvalidate: nginx -t -c %s- name: Restart nginxansible.builtin.service:name: nginxstate: restarted
Plays Stack So You Can Order The Tiers
A playbook is a list, so one file can hold several plays, and Ansible finishes the first one across all of its hosts before it starts the second. That is how you express order between groups of machines: prepare the load balancers, then the app servers sitting behind them.
---- name: Prepare the load balancershosts: lbbecome: truetasks:- name: Ensure haproxy is installedansible.builtin.package:name: haproxystate: present- name: Prepare the app servers behind themhosts: appbecome: truegather_facts: false # nothing here reads a fact, so skip the round triptasks:- name: Create the unprivileged service accountansible.builtin.user:name: appusershell: /usr/sbin/nologin # the account exists; nobody can open a session as itcreate_home: falsesystem: truestate: present
Two details in that second play are worth stealing, and a trap sits right beside them. gather_facts: false skips the setup module for a play that never reads a fact, which removes one connection per host; across a few hundred hosts you feel it on the clock. The trap is that ansible.builtin.package chooses its backend from a gathered fact, so turning facts off in a play that installs packages breaks it. The first play keeps facts on for exactly that reason. The other detail is the shell: /usr/sbin/nologin line, the habit that matters most here. A service account owns files and runs a process and has no business owning an interactive shell, so a stolen appuser credential opens no session. Red Hat documentation writes that path as /sbin/nologin, which since the /usr merge is the same file under a second name, and which is exactly the sort of difference facts exist to handle.
YAML Fails Loudly, Then Sometimes Quietly
Everyone meets the YAML parser eventually, so meet it deliberately. Copy a task off a web page, paste it into your file, and your editor helpfully inserts a tab character. A tab is invisible ink here. You cannot see it on screen, and YAML flatly refuses to accept it as indentation.
$ ansible-playbook -i inventory.ini site.yml --syntax-check
ERROR! Syntax Error while loading YAML.found character '\t' that cannot start any tokenThe error appears to be in '/home/deploy/infra/site.yml': line 7, column 1, but maybe elsewhere in the file depending on the exact syntax problem.The offending line appears to be:tasks:- name: Install nginx^ here
That is the loud failure, and it is the kind one. It names the file, the line, the column, and prints the offending text with a caret under it. Quiet failures hurt more, because the file parses cleanly and means something you never intended. Three rules head off most of them. Never use tabs, only spaces. Quote any value that contains a colon followed by a space, or that starts with %, @, {, [, * or &, since those characters open YAML structures. And write file permissions as quoted strings, mode: "0644". An unquoted 644 is the decimal number six hundred and forty-four, which lands on disk as mode 1204, sticky bit and all; unquoted 0644 happens to be read as octal by the parser Ansible uses today, and quoting takes the question off the table.
Then run ansible-lint site.yml, which catches a whole layer that ansible-playbook accepts without complaint: bare module names instead of FQCNs (rule fqcn[action-core]), tasks with no name at all (name[missing]), and reaching for shell where a real module already exists (command-instead-of-module). Wire it into CI (continuous integration, the robot that checks every push) alongside --syntax-check, and the review conversation stops being about indentation and starts being about the change.
Try this
Run ansible-playbook -i inventory.ini site.yml --syntax-check on a scratch host or disposable cluster and read the output against what this lesson described. Then change one input so it fails, and re-run: the error you get is the one you will meet in production.
Takeaway
The trap worth remembering here: become: true hands root to every task in the play. Check that on your own systems before you need to, because it is cheaper to find on a quiet afternoon than during an incident.