courses

Ansible: Writing Playbooks From Scratch

Why this matters

You already know how to configure a server: SSH in, edit files, install packages, restart services. That works exactly once. It does not survive the machine being rebuilt, it cannot be reviewed by a colleague, and six months later nobody — including you — can say with confidence what is actually on the box or why.

Ansible replaces that with a file you can read, diff, and commit. The file describes the state you want, not the steps to get there, and running it against a machine that is already in that state does nothing at all. That property — idempotency — is what makes configuration management different from a shell script, and it is the thing most people get wrong first.

This page is the “write your own from scratch” reference. The Infrastructure as Code page introduces Ansible alongside Terraform and Pulumi and shows what it looks like; this one covers enough of the language, the variable system, and the failure modes to let you sit down in front of an empty file and produce something that works. The lab’s own playbooks — the ones in the lab repository — are written using everything below, and you will be extending them.

Everything on this page was run against ansible-core 2.21.2 and the transcripts are real.

The mental model

Four ideas, and the rest is syntax.

1. It is agentless and push-based. There is no daemon on the managed host. Your workstation (the control node) opens an SSH connection, copies a small Python program over, runs it, collects the JSON it prints, and deletes it. The only requirements on the target are SSH access and a Python interpreter.

2. You declare state, not steps. state: present on a package, state: directory on a path. You are not writing if not installed, install — the module does that check for you.

3. Every task reports ok, changed, or failed. This is the heart of the system. changed means the module had to do something. A well-written playbook run against an already-configured host reports changed=0, and that number is your regression test.

4. Modules do the work; you never call the shell if a module exists. There are over 8,600 modules available in a default install. Reaching for command: or shell: is almost always a sign you have not found the right one yet.

Setting up a control node

sudo apt-get install -y ansible          # Debian/Ubuntu/Kali
ansible --version
$ ansible --version
ansible [core 2.21.2]
  config file = None
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = /home/dmcgrath/.ansible/collections:/usr/share/ansible/collections
  python version = 3.13.14 (main, Jun 10 2026, 18:10:12) [GCC 15.2.0] (/usr/bin/python3)
  jinja version = 3.1.6

The project layout

Start every project with this shape. Ansible finds things by convention, and fighting the convention costs you more than following it:

myproject/
├── ansible.cfg          # project-local settings; found automatically
├── inventory.ini        # which hosts exist and how to reach them
├── site.yml             # the top-level playbook
├── group_vars/
│   ├── all.yml          # variables for every host
│   └── webservers.yml   # variables for the `webservers` group
├── host_vars/
│   └── web1.yml         # variables for one host
└── roles/
    └── nginx/           # reusable units (see below)

ansible.cfg in the current directory is picked up automatically, so you can stop typing -i inventory.ini on every command:

[defaults]
inventory = inventory.ini
host_key_checking = False
stdout_callback = yaml

⚠️ host_key_checking = False is fine for a disposable lab and wrong for production. Turning it off means you accept any host key, which is precisely the man-in-the-middle protection SSH exists to provide. In real environments, pre-populate known_hosts instead.

Inventory

The INI format is the quickest to read:

[webservers]
web1 ansible_host=10.0.0.1
web2 ansible_host=10.0.0.2

[dbservers]
db1 ansible_host=10.0.0.10

[production:children]
webservers
dbservers

[all:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/id_ed25519

Here is the lab’s real inventory, which shows two things worth copying: per-host variables carrying meaning your playbooks can use, and ansible_ssh_common_args to reach hosts behind a jump box without touching ~/.ssh/config:

[ubuntu_server]
172.20.100.105 ansible_ssh_common_args='-o ProxyJump=systemsec-06' hostname=ubuntu-server internal_ip=10.10.10.20

[wazuh_server]
172.20.100.102 ansible_ssh_common_args='-o ProxyJump=systemsec-06' hostname=wazuh internal_ip=10.10.10.30

Prove connectivity before you write anything

$ ansible all -m ansible.builtin.ping
172.20.100.104 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3.13"
    },
    "changed": false,
    "ping": "pong"
}
172.20.100.102 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
172.20.100.105 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

ping here is not ICMP — it is a module that connects over SSH, starts Python, and returns pong. It tests the whole chain. If this fails, no playbook will work, and debugging it here is far easier than debugging it inside a 200-line play.

Finding modules yourself

This is the single most important skill on this page, because it is what turns “I can read a playbook” into “I can write one”. You are not expected to memorise modules. You are expected to look them up.

ansible-doc -l                          # list every module (8,600 in a default install)
ansible-doc -l | grep -i firewall       # find candidates
ansible-doc ansible.builtin.file        # full docs, including every option and examples
ansible-doc -s ansible.builtin.file     # short form: a ready-to-paste task skeleton

ansible-doc -s prints a skeleton you can paste straight into a playbook:

$ ansible-doc -s ansible.builtin.file
- name: Manage files and file properties
  file:
      access_time:           # This parameter indicates the time the file's
                             # access time should be set to...
      access_time_format:    # When used with `access_time', indicates the time
                             # format that must be used...

The workflow for any new task is: describe what you want in English → ansible-doc -l | grep for it → read ansible-doc <module> → write the task. The modules you will use constantly:

Module Purpose
ansible.builtin.apt / dnf / package Install packages (package is distro-agnostic)
ansible.builtin.copy Push a file or literal content
ansible.builtin.template Push a Jinja2-rendered file
ansible.builtin.file Create/remove paths, set ownership and mode
ansible.builtin.lineinfile / replace Surgical edits to files you do not own
ansible.builtin.systemd_service Start, stop, enable, restart units
ansible.builtin.user / group Accounts
ansible.builtin.git Check out a repository
ansible.builtin.uri HTTP requests (health checks, API calls)
ansible.builtin.command / shell Last resort — see the warning below
community.docker.docker_container Manage containers
ansible.posix.sysctl / mount Kernel parameters, filesystems

⚠️ command and shell are not idempotent and Ansible cannot make them so. They report changed every single run unless you tell them otherwise with changed_when, which poisons your changed=0 signal. shell additionally runs through /bin/sh, so any variable you interpolate into it is a command injection sink — the same class of bug you study on the SAST page, just in YAML. Use command over shell when you have no choice (it takes an argv list, no shell involved), and always pair it with creates:, removes:, or changed_when:.

Your first playbook, and what idempotency looks like

- name: Idempotency demonstration
  hosts: demo
  gather_facts: false
  vars:
    workdir: /tmp/ansible-demo
  tasks:
    - name: Ensure the working directory exists
      ansible.builtin.file:
        path: ""
        state: directory
        mode: "0755"

    - name: Write a config file
      ansible.builtin.copy:
        dest: "/app.conf"
        content: |
          listen = 8080
          workers = 4
        mode: "0644"

Run it twice:

########## FIRST RUN ##########
PLAY [Idempotency demonstration] ***********************************************

TASK [Ensure the working directory exists] *************************************
changed: [localhost]

TASK [Write a config file] *****************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=2    unreachable=0    failed=0

########## SECOND RUN ##########
TASK [Ensure the working directory exists] *************************************
ok: [localhost]

TASK [Write a config file] *****************************************************
ok: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=2    changed=0    unreachable=0    failed=0

changed=2 then changed=0. Make this your habit: run every playbook twice and confirm the second run is clean. A task that reports changed on every run is a bug, even when the end state is correct — it means you cannot tell real drift from noise, and it will fire handlers (restarting services) that had no reason to fire.

Anatomy of a play

- name: Human-readable description          # shows in output; always write one
  hosts: webservers                         # pattern: group, host, all, web*, !db
  become: true                              # escalate to root (sudo by default)
  become_user: postgres                     # ...or to a specific user
  gather_facts: true                        # collect system facts before running
  serial: 2                                 # rolling update: 2 hosts at a time
  any_errors_fatal: false                   # abort all hosts if one fails?
  vars:
    app_port: 8080
  vars_files:
    - secrets.yml
  pre_tasks: []                             # run before roles
  roles: []                                 # roles, in order
  tasks: []                                 # then tasks
  post_tasks: []                            # then these
  handlers: []                              # triggered by notify

Execution order is fixed: pre_tasks → handlers triggered by them → rolestaskspost_tasks → remaining handlers. Knowing this matters when a role needs a package that another play installed.

serial: 2 deserves a mention in a security context: it is how you roll a change across a fleet without taking the whole service down if the change is bad. Combined with a health check in post_tasks, it is the difference between a bad patch affecting two hosts and a bad patch affecting two hundred.

Variables

Where they come from

Roughly in increasing priority (the full list has 22 levels; these are the ones you will actually hit):

Source Typical use
roles/x/defaults/main.yml Role defaults — lowest priority, meant to be overridden
group_vars/all.yml Site-wide settings
group_vars/<group>.yml Per-group settings
host_vars/<host>.yml Per-host settings
Inventory host_var=value Quick per-host values
Play vars: / vars_files: Values for this play
roles/x/vars/main.yml Role internals — high priority, not meant to be overridden
Task vars: One task
--extra-vars / -e Wins over everything

Demonstrated:

$ ansible-playbook prec.yml
TASK [Play vars beat inventory and role defaults] ******************************
ok: [localhost] => { "greeting": "from play vars" }

TASK [Task vars beat play vars] ************************************************
ok: [localhost] => { "greeting": "from task vars" }

$ ansible-playbook prec.yml -e '{"greeting": "from extra vars"}'
ok: [localhost] => { "greeting": "from extra vars" }
ok: [localhost] => { "greeting": "from extra vars" }

Note that -e overrode even the task-level vars: — that is what “wins over everything” means, and it is why -e is the right tool for a one-off override and the wrong tool for anything permanent.

The practical rule: put overridable knobs in defaults/, put internals in vars/. If you ever find yourself unable to override a value from a group_vars file, it is almost always because it was put in vars/ instead of defaults/.

⚠️ -e key=value splits on whitespace. The key=value form is not shell-quoted the way you expect:

$ ansible-playbook prec.yml -e "greeting=from extra vars"
ok: [localhost] => { "greeting": "from" }

The value silently became from, and the rest was parsed as more variables. Use the JSON form — -e '{"greeting": "from extra vars"}' — for any value containing a space.

Reserved names

$ ansible-playbook handlers.yml -e port=7777
[WARNING]: Found variable using reserved name 'port'.

Some names collide with Ansible’s own connection variables. port, hosts, name, environment, and user are common casualties. Prefix your variablesapp_port, nginx_user — which also makes role variables self-documenting about where they came from.

Facts

With gather_facts: true (the default), Ansible collects several hundred pieces of information about each host before running any task:

ansible localhost -m ansible.builtin.setup                    # dump all facts
ansible localhost -m ansible.builtin.setup -a 'filter=ansible_distribution*'

Use them to write one playbook that works across distributions:

- name: Install the web server
  ansible.builtin.package:
    name: "apache2"
    state: present

⚠️ The bare ansible_distribution form is deprecated. ansible-core 2.21 warns:

[DEPRECATION WARNING]: INJECT_FACTS_AS_VARS default to `True` is deprecated,
top-level facts will not be auto injected after the change. This feature will
be removed from ansible-core version 2.24.
Use `ansible_facts["fact_name"]` (no `ansible_` prefix) instead.

Write ansible_facts['distribution'], not ansible_distribution. Every tutorial you find online uses the old form; it has a removal date.

Fact gathering costs a round trip per host. Set gather_facts: false on plays that do not need facts — it is a noticeable speedup on large inventories.

Templates

ansible.builtin.template renders a Jinja2 file on the control node and pushes the result. This is how configuration files should be managed: one template, many hosts, values from variables.

templates/nginx.conf.j2:

# Managed by Ansible — local edits will be overwritten.
server {
    listen ;
    server_name ;

    

    
}
- name: Deploy the nginx site config
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/sites-available/app.conf
    mode: "0644"
    owner: root
    group: root
    validate: /usr/sbin/nginx -t -c %s     # refuse to install a broken config
  notify: Reload nginx

Two habits worth adopting immediately. First, that “Managed by Ansible” header — it tells the next person why their edit vanished. Second, validate: — Ansible renders to a temporary file, runs your validation command against it (%s is substituted with the temp path), and only installs it if the command succeeds. Without it, a typo in a template takes the service down on every host simultaneously.

Filters you will use constantly: ,, ,, ,, ``.

Control flow

- name: Loops, conditionals, and error handling
  hosts: demo
  vars:
    packages:
      - { name: nginx,       required: true }
      - { name: htop,        required: false }
      - { name: obscuretool, required: false }
  tasks:
    - name: Loop over a list of dicts, skipping the optional ones
      ansible.builtin.debug:
        msg: "would install "
      loop: ""
      when: item.required | bool
      loop_control:
        label: ""

    - name: Run a command and decide for yourself what counts as changed
      ansible.builtin.command: /usr/bin/id -un
      register: whoami
      changed_when: false

    - name: Use the registered result
      ansible.builtin.debug:
        msg: "remote user is "

    - name: Recover from a failure instead of aborting the play
      block:
        - name: This task fails on purpose
          ansible.builtin.command: /bin/false
      rescue:
        - name: Handle the failure
          ansible.builtin.debug:
            msg: "primary path failed, falling back"
      always:
        - name: This runs either way
          ansible.builtin.debug:
            msg: "cleanup"
TASK [Loop over a list of dicts, skipping the optional ones] *******************
ok: [localhost] => (item=nginx) => { "msg": "would install nginx" }
skipping: [localhost] => (item=htop)
skipping: [localhost] => (item=obscuretool)

TASK [Run a command and decide for yourself what counts as changed] ************
ok: [localhost]

TASK [Use the registered result] ***********************************************
ok: [localhost] => { "msg": "remote user is dmcgrath" }

TASK [This task fails on purpose] **********************************************
fatal: [localhost]: FAILED! => {"changed": true, "cmd": ["/bin/false"], "rc": 1, ...}

TASK [Handle the failure] ******************************************************
ok: [localhost] => { "msg": "primary path failed, falling back" }

TASK [This runs either way] ****************************************************
ok: [localhost] => { "msg": "cleanup" }

PLAY RECAP *********************************************************************
localhost                  : ok=7    changed=0    unreachable=0    failed=0    rescued=1

Four things to take from that transcript:

The related knobs: failed_when: result.rc not in [0, 2] to redefine failure, ignore_errors: true to continue past one (blunt — prefer failed_when), until:/retries:/delay: to poll, and delegate_to: localhost to run a task somewhere other than the target (health checks, DNS updates, notifications).

Handlers

A handler is a task that runs only if something notified it, and only once at the end of the play no matter how many tasks notified it. This is how you restart a service if and only if its config actually changed.

  handlers:
    - name: Reload the app
      ansible.builtin.command: /bin/echo "RELOAD would happen here"
      changed_when: false

  tasks:
    - name: Write a config file
      ansible.builtin.copy:
        dest: "/app.conf"
        content: |
          listen = 
          workers = 4
      notify: Reload the app
===== run 1: port changes 8080 -> 9090 (handler should fire) =====
TASK [Write a config file] *****************************************************
changed: [localhost]

RUNNING HANDLER [Reload the app] ***********************************************
ok: [localhost]

PLAY RECAP: localhost : ok=2  changed=1

===== run 2: same port (handler must NOT fire) =====
TASK [Write a config file] *****************************************************
ok: [localhost]

PLAY RECAP: localhost : ok=1  changed=0

The handler does not appear at all in the second run. That is the entire point, and it is why chasing changed=0 matters: a task that spuriously reports changed will restart production services for no reason.

notify matches on the handler’s name — an exact string match, so a typo means silent non-execution with no error. If a handler is not firing, check the spelling first. If you need a handler to run now rather than at the end of the play, use ansible.builtin.meta: flush_handlers.

Check mode and diff

--check runs the playbook without changing anything; --diff shows what would change. Together they are your code review tool.

$ ansible-playbook handlers.yml -e app_port=7777 --check --diff

TASK [Write a config file] *****************************************************
--- before: /tmp/ansible-demo/app.conf
+++ after: /tmp/ansible-demo/app.conf
@@ -1,2 +1,2 @@
-listen = 9090
+listen = 7777
 workers = 4

changed: [localhost]

RUNNING HANDLER [Reload the app] ***********************************************
skipping: [localhost]

PLAY RECAP: localhost : ok=1  changed=1  skipped=1

$ cat /tmp/ansible-demo/app.conf
listen = 9090
workers = 4

The file on disk is untouched. Run --check --diff against production before every real run — it is the closest thing configuration management has to a git diff.

Two caveats. Modules must support check mode; command and shell skip entirely rather than pretend, so a play that depends on a command’s side effects will report inaccurately. And check mode can produce false failures when task B depends on something task A would have created.

Tags

    - name: Install packages
      ansible.builtin.package:
        name: nginx
        state: present
      tags: [packages, nginx]
ansible-playbook site.yml --tags nginx          # only tagged tasks
ansible-playbook site.yml --skip-tags slow      # everything except
ansible-playbook site.yml --list-tags           # what tags exist
ansible-playbook site.yml --list-tasks          # what would run

Tag by what the task configures, not by how long it takes. --tags firewall is useful a year from now; --tags step3 is not.

Roles

A role is a directory with a fixed layout that Ansible loads automatically. Generate the skeleton rather than typing it:

$ ansible-galaxy init --init-path roles webserver
- Role webserver was created successfully

roles/webserver/
├── defaults/main.yml     # overridable variables — put your knobs here
├── files/                # static files for copy:
├── handlers/main.yml     # restart/reload handlers
├── meta/main.yml         # dependencies, metadata
├── tasks/main.yml        # the entry point
├── templates/            # .j2 files for template:
├── tests/                # a sample inventory and playbook
└── vars/main.yml         # internal variables — high precedence

Inside a role, src: paths are resolved relative to files/ and templates/ automatically — write src: nginx.conf.j2, not a path.

- name: Configure the web tier
  hosts: webservers
  become: true
  roles:
    - role: webserver
      vars:
        app_port: 8443

When to make a role: when the same set of tasks applies to more than one group of hosts, or when a single tasks: list has grown past roughly 100 lines. Not before. A three-task playbook split across four role directories is harder to read, not easier.

Collections and fully-qualified names

Modules ship in collections. ansible.builtin is bundled; everything else is installed:

ansible-galaxy collection install community.general
ansible-galaxy collection install community.docker
ansible-galaxy collection list

Pin them in requirements.yml so a rebuild is reproducible:

collections:
  - name: community.general
    version: ">=8.0.0"
  - name: community.docker
ansible-galaxy collection install -r requirements.yml

Always write the fully-qualified nameansible.builtin.copy, not copy. Short names still work, but they resolve through a search path, which means an installed collection can silently shadow the module you meant. Every example on this page uses the FQCN for that reason.

⚠️ Roles from Ansible Galaxy are arbitrary code from strangers that you will run as root on every host. This is a supply-chain exposure identical in kind to an npm or PyPI dependency. Pin versions, read the tasks before you run them, and prefer collections published by the vendor (community.*, ansible.posix) over one-off personal roles.

Secrets

Ansible Vault

Vault encrypts files at rest with AES256, so group_vars/secrets.yml can live in git:

$ ansible-vault encrypt group_vars_secrets.yml
$ head -3 group_vars_secrets.yml
$ANSIBLE_VAULT;1.1;AES256
39303261323934636264326437376631623263383336356638633565353030636265626162393737
3666393536306539363136383133346466313365643033300a383565366561346432643863643838

$ ansible-vault view group_vars_secrets.yml
db_password: hunter2
api_token: abc123

Encrypt a single value instead of a whole file with encrypt_string, which lets the rest of the file stay readable and diffable:

$ ansible-vault encrypt_string 'sup3rs3cret' --name 'admin_password'
admin_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          33663932653066336534616563333636333835313164653963393433383931663139316539336137
          3865653031336464333337636263313533303363393639610a653938306338616132643136636137
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass    # mode 600, gitignored

no_log

Vault protects the secret at rest. It does nothing about the secret being printed to your terminal, your CI log, and your ansible.log:

TASK [LEAKS the secret into the log] *******************************************
ok: [localhost] => {"changed": false, "cmd": ["/bin/echo", "hunter2"], ...
"stdout": "hunter2", "stdout_lines": ["hunter2"]}

TASK [Does not leak] ***********************************************************
ok: [localhost] => {"censored": "the output has been hidden due to the fact that
'no_log: true' was specified for this result", "changed": false}

Any task that touches a secret needs no_log: true. Note also that the first task put the password in cmd — the argument vector — which means it was visible in ps output on the target host for the lifetime of the command. Pass secrets through files, environment variables, or module parameters, never through argv.

Vault is not the end state

Vault has real limits: one password (or a small set) shared by everyone who runs the playbook, no rotation story, no audit log, no expiry, and a decryption key that must exist on every control node and CI runner. It is appropriate for a lab and for low-value configuration.

For anything that matters, the playbook should fetch the secret at run time from a secrets manager rather than carry it — see OpenBao for the KV engine, AppRole authentication, and the secure-introduction problem, and CI/CD and Secret Management for how this fits a pipeline. The distinction to hold onto: Vault moves the secret’s blast radius from “the repo” to “everyone with the vault password”, which is progress but not a solution.

Security practices for playbooks

Testing and linting

ansible-playbook site.yml --syntax-check      # parse only, no connection
ansible-lint site.yml                          # style and correctness rules
ansible-playbook site.yml --check --diff       # dry run
ansible-playbook site.yml --list-tasks         # what would run, in order

ansible-lint is a separate package (pip install ansible-lint or apt-get install ansible-lint) and is worth adding to your pipeline — it catches the FQCN issue, missing changed_when on commands, unquoted modes, and use of shell where a module exists. For serious role development, Molecule spins up a container, applies the role, runs assertions, and then applies it again to verify idempotency automatically.

Common failure modes

Symptom Cause
changed on every run A command/shell task with no changed_when, creates, or removes
Handler never runs notify string does not exactly match the handler’s name
Variable is empty Defined in vars/ when you meant defaults/, or overridden by higher precedence
-e value truncated key=value split on whitespace — use the JSON form
Deprecation spam about facts Bare ansible_x instead of ansible_facts['x']
Works by hand, fails in Ansible Non-interactive shell: no PATH from .bashrc, no TTY for sudo
Permission denied mid-play Missing become: true on the task that needs it
Templates render `` literally Used copy: instead of template:
Reserved-name warning Variable named port, name, user, hosts, environment

For debugging, -v through -vvvv increases verbosity (-vvvv shows the SSH commands), and ansible.builtin.debug: var=some_var is the print statement.

Key takeaways

References


Related course pages: Infrastructure as Code · OpenBao · CI/CD, Secret Management, and GitOps · OS Hardening · SAST and Secrets Detection · Proxmox setup

🛠️ Maintenance note: every transcript here was produced with ansible-core 2.21.2 (Jinja 3.1.6, Python 3.13). Two items have known expiry dates and must be re-checked each term: the INJECT_FACTS_AS_VARS deprecation is scheduled for removal in ansible-core 2.24, after which bare ansible_distribution-style variables stop working entirely and the examples using ansible_facts[...] become mandatory rather than merely preferred; and the reserved-variable-name warning list grows between releases, so the port/name/user list in the failure-modes table is a floor, not a ceiling. Module names also migrate between ansible.builtin and community.* across major releases — if an FQCN in an example is rejected, check ansible-doc -l | grep for its new home.