50% OFF on All Courses!

Popular:

Your cart is empty

Your cart is empty

Ansible Playbook Examples: 20 Real Ones You Can Copy and Run

20 real Ansible playbook examples with the exact commands to run them. Linux, Cisco, AWS, Docker. Copy, paste, run. Every one shows what breaks.
Data center server racks with SME Academy logo in the corner.

Most Ansible playbook articles hand you a file that installs nginx, then stop. You still can’t do your actual job.

So here’s the opposite. Below are 20 Ansible playbook examples that do real work: patching mixed Ubuntu and RHEL fleets, pushing a VLAN to 40 switches, backing up configs before you touch them, rolling a container update without dropping traffic.

Every one is complete. Every one has the command that runs it. And every one has a short “what breaks” note, because the thing that stops you isn’t the YAML, it’s the quoting bug at 5pm on a Friday.

Every file here targets ansible-core 2.21.3, released 2026-08-10, and uses fully qualified module names throughout.

Jump to what you need: beginner · Linux servers · security · Cisco and network gear · AWS · containers · how to run them · what breaks most

What an Ansible playbook actually is

An Ansible playbook is a YAML file that lists the work you want done and the hosts you want it done on. It holds one or more plays. Each play maps a group of hosts to an ordered list of tasks, and each task calls a module like apt or copy. Ansible runs the file top to bottom, over SSH, with nothing installed on the targets.

That last part is why people pick it. No agent to deploy, no daemon to babysit.

The difference between an Ansible playbook and an ad hoc command is repeatability. ansible all -m ping answers a question right now. A playbook is a file you commit, review, and run again in six months when the same box drifts.

Diagram of how an Ansible playbook reaches your hosts: one control node holding site.yml and inventory.ini connects over SSH port 22 to webservers, dbservers and switches, with no agent installed on any target.

One control node, one file, and nothing installed on the targets.

The anatomy of every Ansible playbook

Every file you’ll write looks like this. The comments name the parts you’ll meet again in all 20 examples.

---                                   # YAML document start
- name: Configure web servers         # the play, and its label
  hosts: webservers                   # which inventory group to target
  become: true                        # escalate to root
  gather_facts: true                  # collect host facts first
  vars:                               # variables scoped to this play
    app_port: 8080

  tasks:                              # the ordered work
    - name: Install nginx             # a task, and its label
      ansible.builtin.apt:            # the module doing the work
        name: nginx
        state: present
      notify: Restart nginx           # fires a handler, but only on change

  handlers:                           # only run when notified
    - name: Restart nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
KeyWhat it doesRequired
nameHuman label for the play or taskNo, but always write it
hostsWhich inventory group this play targetsYes
becomeEscalate to rootNo
gather_factsCollect host facts, set false to speed upNo
varsVariables scoped to this playNo
tasksThe list of things to doYes
handlersTasks that only run when notifiedNo
Anatomy of an Ansible playbook showing five numbered parts: the play with hosts and become, vars, tasks that call modules, the module itself, and handlers fired by notify.

The five parts, and the two YAML rules that break most first attempts.

Two YAML rules cause most first-day failures. Indentation is two spaces, never a tab, and the file starts with ---. A tab produces an error that points at the wrong line, which is why it costs people an hour.

One more thing about the module names below. They’re written in full, like ansible.builtin.apt instead of apt. Both work, but the full name is what current ansible-core and the official docs use, and it saves you a guessing game when two collections ship a module with the same short name.

Every example assumes you have an inventory. If yours is still a hand-maintained text file, read static vs dynamic Ansible inventory first, because half the failures below are really inventory failures wearing a costume.

Before you run anything: the 4 commands that save you

Learn these before you learn another module. They’re the difference between a change and an incident.

ansible-playbook site.yml --syntax-check     # catches YAML errors, connects to nothing
ansible-playbook site.yml --list-hosts       # shows exactly who you're about to hit
ansible-playbook site.yml --check --diff     # dry run, prints what would change
ansible-playbook site.yml -vvv               # full connection debugging when it fails

--list-hosts is the one people skip and then regret. It answers “did my --limit actually work” before the run, not after.

Four Ansible playbook safety gates in order: syntax-check, list-hosts, check with diff, then the real run, with a warning that a dry run is blind to command and shell tasks.

Run them in this order. Gate 2 is the one people skip and then regret.

An honest caveat about --check. A dry run only predicts what a module can predict. The moment a task uses ansible.builtin.command or ansible.builtin.shell, Ansible has no idea what that command would do, so it either skips the task or reports nothing useful, and any later task that depends on its result gets the wrong answer. A clean --check on a playbook full of shell tasks proves very little. Treat it as a smoke test, not a guarantee.

Beginner Ansible playbook examples

Four files. Together they cover the mechanics you’ll reuse in every Ansible playbook after them.

1. Ping every host and prove your inventory works

Run this before anything else on a new fleet. It tests SSH, sudo, and your inventory in one shot, and changes nothing.

---
- name: Verify connectivity to every host
  hosts: all
  gather_facts: false

  tasks:
    - name: Check Ansible can reach and authenticate
      ansible.builtin.ping:

    - name: Report who answered
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} responded"
ansible-playbook -i inventory.ini ping.yml

What breaks: ansible.builtin.ping isn’t ICMP. It’s a Python round trip, so a host that answers a normal ping in your terminal can still fail here when Python is missing or the SSH key isn’t trusted. If you get a host-key prompt, the run hangs forever instead of failing, so set host_key_checking = False in ansible.cfg for lab work only, never in production.

2. Install a package on every server

The one everyone writes first. Worth studying for one reason: it’s idempotent, and the reason it is matters.

---
- name: Install and start nginx
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Start nginx and enable it at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
ansible-playbook -i inventory.ini nginx.yml

What breaks: state: present installs if missing and does nothing if already there, so a second run reports ok rather than changed. That’s idempotency, and it’s the whole point. Swap in state: latest and you lose it: the task can upgrade the package on any run, which turns a config playbook into an unplanned patch window. Use present unless you specifically mean “upgrade now”.

3. Copy a file and restart a service with a handler

The pattern behind almost every real config change. Push a file, and restart the service only if the file actually changed.

---
- name: Deploy nginx config safely
  hosts: webservers
  become: true

  tasks:
    - name: Copy nginx config into place
      ansible.builtin.copy:
        src: files/nginx.conf
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: '0644'
        backup: true
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
ansible-playbook -i inventory.ini nginx-config.yml --check --diff

What breaks: handlers fire on changed, not on ok. If the file is already identical, the copy reports ok, the handler never runs, and that’s correct behaviour people mistake for a bug. The real trap is mode: 0644 without quotes. YAML reads that as octal-ish nonsense and you can end up with permissions you didn’t ask for. Always quote the mode. Also note backup: true, which leaves a timestamped copy on the host and has saved more weekends than any other single parameter.

Flow showing why an Ansible handler did not run: an unchanged file reports ok and the handler stays idle, while a changed file reports changed, fires notify, and reloads nginx once at the end of the play.

The top lane fires the handler. The bottom lane doesn’t, and that’s correct.

4. Render a config file from a Jinja2 template

Copying a static file stops working the moment two hosts need different values. Templates are the fix.

templates/app.conf.j2:

# Managed by Ansible. Local edits will be overwritten.
server_name {{ inventory_hostname }};
listen {{ app_port }};
worker_processes {{ ansible_processor_vcpus }};
---
- name: Render per-host app config
  hosts: webservers
  become: true
  vars:
    app_port: 8080

  tasks:
    - name: Render app config from template
      ansible.builtin.template:
        src: templates/app.conf.j2
        dest: /etc/app/app.conf
        mode: '0644'
        validate: 'nginx -t -c %s'
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
ansible-playbook -i inventory.ini app-template.yml --check --diff

What breaks: ansible_processor_vcpus only exists because facts were gathered. Set gather_facts: false to speed up a play and every fact variable in your template becomes undefined, which fails at render time with a message that doesn’t mention facts at all. The validate line is the underrated part: it runs a syntax check on the rendered file before installing it, so a bad template fails the task instead of taking down the service on reload.

Ansible playbook examples for Linux server management

5. Patch every Ubuntu and RHEL box in one run

Mixed fleets are normal. This handles both without maintaining two Ansible playbooks.

---
- name: Patch all Linux hosts
  hosts: linux
  become: true
  serial: "25%"

  tasks:
    - name: Update apt cache and upgrade Debian family
      ansible.builtin.apt:
        upgrade: dist
        update_cache: true
        cache_valid_time: 3600
      when: ansible_os_family == "Debian"

    - name: Upgrade RedHat family
      ansible.builtin.dnf:
        name: "*"
        state: latest
      when: ansible_os_family == "RedHat"

    - name: Check whether a reboot is required
      ansible.builtin.stat:
        path: /var/run/reboot-required
      register: reboot_file

    - name: Reboot only if the host asked for it
      ansible.builtin.reboot:
        reboot_timeout: 600
      when: reboot_file.stat.exists
ansible-playbook -i inventory.ini patch.yml --limit staging

What breaks: serial: "25%" patches a quarter of the fleet at a time, so a bad update doesn’t take everything down at once. Without it, Ansible hits all hosts in parallel and you find out about the broken package everywhere simultaneously. Note that /var/run/reboot-required is Debian-family only, so the reboot task simply never fires on RHEL. Fine here, but don’t read a skipped task as “no reboot needed”.

6. Create users and push SSH keys

Onboarding, in a file. The loop is what makes this scale past three people.

---
- name: Manage engineer accounts
  hosts: all
  become: true
  vars:
    engineers:
      - name: rmartin
        key: files/keys/rmartin.pub
      - name: schen
        key: files/keys/schen.pub

  tasks:
    - name: Create the account
      ansible.builtin.user:
        name: "{{ item.name }}"
        groups: sudo
        append: true
        shell: /bin/bash
        state: present
      loop: "{{ engineers }}"

    - name: Install the public key
      ansible.posix.authorized_key:
        user: "{{ item.name }}"
        key: "{{ lookup('file', item.key) }}"
        state: present
        exclusive: true
      loop: "{{ engineers }}"
ansible-playbook -i inventory.ini users.yml

What breaks: authorized_key lives in the ansible.posix collection, not in builtin, so a bare ansible-core install fails with “couldn’t resolve module”. Fix with ansible-galaxy collection install ansible.posix. Watch exclusive: true too. It removes every other key for that user, which is exactly what you want for tight control and exactly how you lock yourself out if the key file is wrong. And the sudo group is Debian naming, so RHEL hosts need wheel.

7. Mount an NFS share that survives a reboot

The failure here is quiet and awful: it works until the box restarts.

---
- name: Mount shared storage persistently
  hosts: appservers
  become: true

  tasks:
    - name: Create the mount point
      ansible.builtin.file:
        path: /mnt/shared
        state: directory
        mode: '0755'

    - name: Mount NFS and write it to fstab
      ansible.posix.mount:
        path: /mnt/shared
        src: "nfs01.internal:/exports/shared"
        fstype: nfs
        opts: "rw,soft,timeo=100"
        state: mounted
ansible-playbook -i inventory.ini nfs.yml

What breaks: the states look similar and behave very differently. state: mounted mounts now and writes fstab. state: present only writes fstab. Pick present by accident and nothing is mounted until the next reboot. Also use soft rather than hard in opts unless you want processes hanging unkillably when the NFS server goes away.

8. Schedule a cron job the idempotent way

Never append to crontab with shell. You’ll get eleven copies of the same job.

---
- name: Schedule nightly database backup
  hosts: dbservers
  become: true

  tasks:
    - name: Nightly backup at 02:15
      ansible.builtin.cron:
        name: "nightly db backup"
        minute: "15"
        hour: "2"
        job: "/usr/local/bin/backup.sh >> /var/log/backup.log 2>&1"
        user: root
        state: present
ansible-playbook -i inventory.ini cron.yml

What breaks: the name field is the identity of the job. Ansible writes it as a comment and uses it to find the entry next run. Change the name and you get a second cron job instead of an edited one. Keep the name stable and change the schedule freely.

Ansible playbook examples for security and hardening

9. Lock down SSH (no root, no passwords)

The Ansible playbook most likely to lock you out of 200 servers, so it’s written defensively.

---
- name: Harden SSH daemon
  hosts: all
  become: true

  tasks:
    - name: Disable root login and password auth
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
        state: present
        validate: '/usr/sbin/sshd -t -f %s'
      loop:
        - { regexp: '^#?PermitRootLogin', line: 'PermitRootLogin no' }
        - { regexp: '^#?PasswordAuthentication', line: 'PasswordAuthentication no' }
      notify: Restart sshd

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted
ansible-playbook -i inventory.ini ssh-hardening.yml --limit jumpbox01 --check --diff

What breaks: run this before your keys are working and you’re locked out with no way back except console access. Test on one host with --limit first, keep that session open, and confirm a new login works in a second terminal before you go wider. The validate line is doing real work: sshd -t refuses a broken config, so the file is never written in a state that would kill the daemon on restart. The ^#? in each regexp matters too, because it matches the setting whether or not it’s currently commented out.

10. Open only the ports you need

Two firewall tools, one playbook, chosen by OS family.

---
- name: Configure host firewall
  hosts: webservers
  become: true
  vars:
    allowed_ports:
      - 22
      - 80
      - 443

  tasks:
    - name: Allow ports with ufw on Debian family
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop: "{{ allowed_ports }}"
      when: ansible_os_family == "Debian"

    - name: Turn ufw on with a default deny
      community.general.ufw:
        state: enabled
        policy: deny
      when: ansible_os_family == "Debian"

    - name: Allow ports with firewalld on RedHat family
      ansible.posix.firewalld:
        port: "{{ item }}/tcp"
        permanent: true
        immediate: true
        state: enabled
      loop: "{{ allowed_ports }}"
      when: ansible_os_family == "RedHat"
ansible-playbook -i inventory.ini firewall.yml --check

What breaks: task order saves you here. Ports are allowed before the default-deny policy is enabled. Flip those two tasks and you drop your own SSH session mid-run. On the firewalld side, leaving out permanent: true gives you a rule that vanishes on reboot, and leaving out immediate: true gives you a rule that does nothing until reboot. You want both.

11. Store secrets properly with ansible-vault

Plain-text passwords in a repo is the most common Ansible mistake in the wild. It takes one command to fix.

ansible-vault create group_vars/all/vault.yml

Inside the encrypted file:

vault_db_password: "the-real-password"
vault_api_token: "the-real-token"
---
- name: Deploy app credentials
  hosts: appservers
  become: true

  tasks:
    - name: Write the app environment file
      ansible.builtin.template:
        src: templates/app.env.j2
        dest: /etc/app/app.env
        owner: root
        mode: '0600'
      no_log: true
ansible-playbook -i inventory.ini secrets.yml --ask-vault-pass

What breaks: no_log: true is not optional on any task that touches a secret. Without it, a failure prints the whole rendered task, secret included, into your terminal and your CI logs. The other trap is that vault encrypts files, not values in your head: ansible-vault encrypt_string exists for single values, but a whole encrypted vault.yml in group_vars is easier to review and rotate.

Ansible playbook examples for Cisco network automation

This is where Ansible earns its keep, and where most tutorials stop. If you’re coming from the CLI, the mental shift is small: you already know the commands, you’re just sending them to 40 devices at once instead of one.

Network modules don’t use become and shouldn’t gather Linux facts. They connect with network_cli instead of a shell.

Your inventory needs the connection details:

[switches]
sw01 ansible_host=10.10.0.11
sw02 ansible_host=10.10.0.12

[switches:vars]
ansible_network_os=cisco.ios.ios
ansible_connection=ansible.netcommon.network_cli

12. Back up every switch config before you touch anything

Run this first, every time, forever. It’s read-only and it’s the reason you’ll sleep after a bad change.

---
- name: Back up running configs
  hosts: switches
  gather_facts: false

  tasks:
    - name: Pull running-config to the control node
      cisco.ios.ios_config:
        backup: true
        backup_options:
          dir_path: ./backups
          filename: "{{ inventory_hostname }}-{{ ansible_date_time.date | default('manual') }}.cfg"
ansible-playbook -i inventory.ini backup-configs.yml

What breaks: backups land on the control node, not on the device, which surprises people looking for them on the switch. If you want the date in the filename you need facts gathered, which is why the default('manual') filter is there as a fallback. Commit the backups/ directory to git and you get a free config-change history with diffs.

One Ansible playbook fanning out from a control node to 40 Cisco switches, pushing three VLANs to each, with a config backup taken first and save_when modified applied after.

One loop, 40 switches, and a backup before anything moves.

13. Push a VLAN to 40 switches

The example that sells automation to a network team. One file replaces 40 SSH sessions.

---
- name: Standardise access VLANs
  hosts: switches
  gather_facts: false
  vars:
    vlans:
      - { id: 110, name: USERS }
      - { id: 120, name: VOICE }
      - { id: 130, name: IOT }

  tasks:
    - name: Create VLANs
      cisco.ios.ios_config:
        lines:
          - "name {{ item.name }}"
        parents: "vlan {{ item.id }}"
      loop: "{{ vlans }}"
      register: vlan_push

    - name: Save config only where something changed
      cisco.ios.ios_config:
        save_when: modified
      when: vlan_push is changed
ansible-playbook -i inventory.ini vlans.yml --check --diff --limit sw01

What breaks: parents is what puts you in the right config context. Drop it and name USERS gets typed at the global prompt, which either errors or does something you didn’t intend. And save_when: modified matters on real gear: forget to save and your VLANs disappear at the next power cycle. Always dry-run against one switch with --limit before the other 39.

14. Standardise SNMP, NTP and syslog across the estate

Config drift is the quiet enemy. This Ansible playbook makes “what should be on every device” a file you can review.

---
- name: Apply baseline management config
  hosts: switches
  gather_facts: false
  vars:
    ntp_servers: ["10.0.0.5", "10.0.0.6"]
    syslog_host: "10.0.0.20"
    snmp_community: "{{ vault_snmp_ro }}"

  tasks:
    - name: Configure NTP
      cisco.ios.ios_config:
        lines: "ntp server {{ item }}"
      loop: "{{ ntp_servers }}"

    - name: Configure syslog and SNMP
      cisco.ios.ios_config:
        lines:
          - "logging host {{ syslog_host }}"
          - "snmp-server community {{ snmp_community }} RO"
      no_log: true
      notify: Save running config

  handlers:
    - name: Save running config
      cisco.ios.ios_config:
        save_when: always
ansible-playbook -i inventory.ini baseline.yml --check --diff

What breaks: an SNMP community string is a credential. It belongs in vault with no_log: true on the task, same as any password. The awkward part of --diff on network devices is that it shows the commands Ansible intends to send, not a full before-and-after of the config, so read it as an intent list rather than a true diff.

15. Diff running config against your golden config

Compliance checking without changing a thing. Point it at your intended config and it reports the gap.

---
- name: Check config compliance
  hosts: switches
  gather_facts: false

  tasks:
    - name: Compare running config against intended
      cisco.ios.ios_config:
        diff_against: intended
        intended_config: "{{ lookup('file', 'golden/' + inventory_hostname + '.cfg') }}"
      check_mode: true
      register: compliance

    - name: Flag devices that drifted
      ansible.builtin.debug:
        msg: "{{ inventory_hostname }} has drifted from golden config"
      when: compliance is changed
ansible-playbook -i inventory.ini compliance.yml --diff

What breaks: check_mode: true on the task pins it to read-only no matter how the Ansible playbook gets invoked, which is safer than trusting whoever runs it to remember --check. Expect noise on the first run: timestamps, counters and auto-generated lines will show as differences, so your golden files need those stripped out before the report is useful.

16. Pull hosts straight from NetBox

Hand-maintained inventory files go stale within a week. This makes your source of truth the source of truth.

inventory/netbox.yml:

---
plugin: netbox.netbox.nb_inventory
api_endpoint: https://netbox.internal
token: "{{ lookup('env', 'NETBOX_TOKEN') }}"
validate_certs: true
config_context: false
group_by:
  - device_roles
  - sites
query_filters:
  - status: active
ansible-inventory -i inventory/netbox.yml --graph
ansible-playbook -i inventory/netbox.yml backup-configs.yml

What breaks: test with ansible-inventory --graph before you run an Ansible playbook against it. That prints the groups NetBox generated, and the group names are rarely what you assumed, so a playbook targeting hosts: switches matches nothing and reports a successful run against zero hosts. A green run on an empty host list is the most misleading output in Ansible. The full walkthrough is in our guide on building a NetBox dynamic inventory.

Pushing config to real gear is the skill that moves a network engineer up a pay band. Our CCNA Automation course builds exactly these playbooks against live devices, with an instructor in the room when your run fails. If you’re weighing whether it’s worth it, we published what automation engineers actually earn.

Ansible playbook examples for AWS

17. Launch a tagged EC2 instance

Untagged instances are how cloud bills become mysteries. Tag at creation, not later.

---
- name: Provision an application instance
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Launch EC2 instance
      amazon.aws.ec2_instance:
        name: "app-{{ env }}-01"
        instance_type: t3.small
        image_id: ami-0abcdef1234567890
        region: ca-central-1
        vpc_subnet_id: subnet-0123456789abcdef0
        security_group: app-sg
        state: running
        wait: true
        tags:
          Environment: "{{ env }}"
          Owner: platform-team
          CostCentre: engineering
      register: ec2
ansible-playbook launch-ec2.yml -e "env=staging"

What breaks: cloud modules run on the control node, not on a remote host, so hosts: localhost with connection: local is required. Point this at a server group and it tries to install boto3 on every one of them. AMI IDs are region-specific, so an ID copied from a us-east-1 tutorial simply doesn’t exist in ca-central-1. And name: here is a tag, not an identity check, so running this twice can give you two instances unless you filter on existing state first.

18. Create an S3 bucket with versioning and encryption

Three parameters that turn a bucket from a liability into a backup target.

---
- name: Create a secured backup bucket
  hosts: localhost
  connection: local
  gather_facts: false

  tasks:
    - name: Create bucket with versioning and encryption
      amazon.aws.s3_bucket:
        name: smenode-backups-ca-central-1
        state: present
        region: ca-central-1
        versioning: true
        encryption: "AES256"
        public_access:
          block_public_acls: true
          block_public_policy: true
          ignore_public_acls: true
          restrict_public_buckets: true
        tags:
          Purpose: backups
ansible-playbook s3-bucket.yml --check

What breaks: bucket names are globally unique across all of AWS, so a generic name fails with an error that reads like a permissions problem. Versioning is also a one-way door: you can suspend it but never remove the versions already stored, so it’s worth knowing that it changes your storage bill before you turn it on across every bucket.

Ansible playbook examples for containers

19. Install Docker and run a container with a health check

A container that’s “running” and a container that’s working are different things. The health check is what tells them apart.

---
- name: Deploy containerised app
  hosts: appservers
  become: true

  tasks:
    - name: Install Docker prerequisites
      ansible.builtin.package:
        name:
          - docker.io
          - python3-docker
        state: present

    - name: Make sure Docker is running
      ansible.builtin.service:
        name: docker
        state: started
        enabled: true

    - name: Run the app container
      community.docker.docker_container:
        name: web-app
        image: nginx:1.27
        state: started
        restart_policy: unless-stopped
        published_ports:
          - "8080:80"
        healthcheck:
          test: ["CMD", "curl", "-f", "http://localhost/"]
          interval: 30s
          timeout: 10s
          retries: 3
ansible-playbook -i inventory.ini docker-app.yml

What breaks: python3-docker is on the target host, not your laptop, and without it the docker module fails with “Failed to import the required Python library”. Pin the image tag too. Using nginx:latest means the container silently changes version between runs, which quietly destroys the reproducibility you bought Ansible for.

20. Zero-downtime rolling update with serial

The closer. This one updates a fleet behind a load balancer without dropping traffic, and stops the whole rollout if a batch goes bad.

---
- name: Rolling application update
  hosts: appservers
  become: true
  serial: 2
  max_fail_percentage: 0
  vars:
    app_version: "2.4.1"

  tasks:
    - name: Take host out of the load balancer
      ansible.builtin.uri:
        url: "http://lb.internal/api/drain/{{ inventory_hostname }}"
        method: POST
        status_code: 200
      delegate_to: localhost

    - name: Update and verify, roll back on failure
      block:
        - name: Pull the new image and restart
          community.docker.docker_container:
            name: web-app
            image: "myregistry/web-app:{{ app_version }}"
            state: started
            restart: true
            pull: always

        - name: Wait for the app to answer
          ansible.builtin.uri:
            url: "http://{{ inventory_hostname }}:8080/health"
            status_code: 200
          register: health
          retries: 12
          delay: 5
          until: health.status == 200

      rescue:
        - name: Roll back to the previous image
          community.docker.docker_container:
            name: web-app
            image: "myregistry/web-app:{{ previous_version }}"
            state: started
            restart: true

        - name: Fail loudly so the rollout stops
          ansible.builtin.fail:
            msg: "{{ inventory_hostname }} failed health check, rolled back"

    - name: Put host back in the load balancer
      ansible.builtin.uri:
        url: "http://lb.internal/api/enable/{{ inventory_hostname }}"
        method: POST
        status_code: 200
      delegate_to: localhost
Ansible rolling update with serial 2: six app hosts in three batches of two, each drained from the load balancer, updated, health checked and re-added, with a rescue block that rolls back and stops the rollout.

serial: 2 plus max_fail_percentage: 0 is what makes this safe.

ansible-playbook -i inventory.ini rolling-update.yml -e "app_version=2.4.2 previous_version=2.4.1"

What breaks: serial: 2 plus max_fail_percentage: 0 is the combination that makes this safe. Two hosts at a time, and any failure halts the run instead of marching through the rest of the fleet. Note delegate_to: localhost on the load-balancer calls, because that request has to come from the control node, not from the host being drained. And a rescue block only catches failures inside its block, so anything above or below it is unprotected.

How do you run an Ansible playbook?

The base command is ansible-playbook, an inventory, and a file:

ansible-playbook -i inventory.ini site.yml

Everything else is flags, and these are the ones you’ll use weekly.

FlagWhat it doesExample
-iPoints at an inventory file or script-i inventory.ini
--limitRestricts the run to some hosts--limit web01,web02
--tagsRuns only tasks with these tags--tags deploy
--skip-tagsRuns everything except these tags--skip-tags slow
-ePasses extra variables, highest priority-e "app_version=2.4.2"
--start-at-taskResumes from a named task--start-at-task "Copy config"
-C / --checkDry run, changes nothing--check --diff
-bBecome root-b
--ask-vault-passPrompts for the vault password
-fParallel forks, default 5-f 25

To run an Ansible playbook locally against the machine you’re sitting on, no SSH involved:

ansible-playbook -i localhost, -c local local-setup.yml

The trailing comma in -i localhost, isn’t a typo. It tells Ansible to read that as an inline host list rather than a filename.

One flag worth knowing about: -f 25 raises parallelism from the default 5. On a 200-host patch run that’s the difference between 20 minutes and two hours. Raise it carefully, because your control node’s CPU and your SSH limits are the ceiling.

Ansible roles vs playbooks: when to stop writing one file

An Ansible playbook is a file. A role is a directory with a fixed structure that a playbook calls. Roles exist so you can reuse work instead of copying it.

Here’s the practical threshold: move to roles the second time you copy a task block from one playbook into another, or once a single file passes roughly 100 lines. Before that, roles add ceremony you don’t need.

A role looks like this:

roles/
  webserver/
    tasks/main.yml
    handlers/main.yml
    templates/
    files/
    defaults/main.yml
    vars/main.yml

And the playbook shrinks to almost nothing:

---
- name: Build web servers
  hosts: webservers
  become: true
  roles:
    - webserver
    - monitoring

The one rule people get wrong: defaults/main.yml holds values you expect users to override, and vars/main.yml holds values you don’t. Put a value in vars/ and it beats almost everything else, including your inventory, which produces variable precedence bugs that take hours to find.

7 mistakes that break Ansible playbooks

These are the error messages, in the form you’ll actually see them.

1. ansible-playbook: command not found Installed with sudo pip install ansible and the binary landed somewhere off your PATH, usually ~/.local/bin. Either add that to PATH or install with pipx install ansible instead, which handles it for you.

2. found character '\t' that cannot start any token A tab in your YAML. Set your editor to insert two spaces for YAML files and it never happens again.

3. MODULE FAILURE with no useful detail Almost always a missing Python interpreter or missing library on the target. Re-run with -vvv and read the raw output, which usually names the import that failed.

4. Your Ansible playbook reports changed on every single run You used ansible.builtin.command or shell where a real module exists. Those two can’t detect state, so they always report changed. Either find the proper module or add a creates: or changed_when: condition.

5. couldn't resolve module/action 'community.general.ufw' The collection isn’t installed. ansible-galaxy collection install community.general. Bare ansible-core ships very few modules on purpose.

6. template error while templating string: unexpected '{' Unquoted Jinja2 at the start of a YAML value. port: {{ app_port }} breaks. port: "{{ app_port }}" works. Quote every value that starts with a brace.

7. An Ansible playbook that succeeds against zero hosts --limit typo, or a group name that doesn’t exist. Ansible reports a clean run and does nothing. Confirm with --list-hosts first.

Run ansible-lint over your repo and it catches four of these before you ever execute a task.

Frequently asked questions

What is an Ansible playbook used for?

An Ansible playbook automates repeated work across many machines at once: installing and configuring software, pushing config to network devices, provisioning cloud resources, and orchestrating multi-step deployments. Because it’s a file, it’s also documentation of what your infrastructure is supposed to look like, and you can commit it, review it, and run it again months later with the same result.

How do I write an Ansible playbook?

Create a .yml file starting with ---. Add a play with hosts: naming an inventory group and tasks: listing the work. Each task gets a name and a module, like ansible.builtin.apt. Check it with ansible-playbook file.yml --syntax-check, then dry-run it with --check --diff before running it for real.

How do I run an Ansible playbook without making changes?

Use ansible-playbook site.yml --check --diff. That’s dry-run mode, and --diff prints the file changes it would make. One caveat: tasks using command or shell can’t be predicted, so they’re skipped or report nothing useful, which means a clean dry run on a shell-heavy playbook doesn’t prove much.

Why do I get “ansible-playbook: command not found”?

The binary isn’t on your PATH, which normally happens after a pip install --user. It usually lands in ~/.local/bin. Add that directory to your PATH, or reinstall with pipx install ansible, which sets it up correctly.

What is the difference between an Ansible role and a playbook?

An Ansible playbook is a single YAML file that says which hosts get which tasks. A role is a structured directory of reusable tasks, handlers, templates and defaults that playbooks call by name. Use a playbook until you copy the same tasks into a second file, then convert that shared part into a role.

Is Ansible still worth learning in 2026?

Yes, and it’s still actively developed: ansible-core 2.21.3 shipped on 2026-08-10, with the community package updated on 2026-08-14. In practice it isn’t competing with Terraform so much as sitting next to it. Terraform creates the infrastructure, Ansible configures what runs on it. On the network side it has very little competition for multi-vendor config management.

How long does it take to learn Ansible?

If you already know Linux and have seen YAML, you can write useful playbooks in a week and be productive within a month. Coming in without Linux fundamentals, expect longer, because most Ansible problems are really SSH, permissions, or package-manager problems. The concepts are small. The debugging is what takes time.

Where to go next

Copy the Ansible playbooks above, change the hostnames, and run them with --check first. That’s genuinely the fastest way to learn this. If you don’t have hosts to test against, a Proxmox homelab gives you a fleet to break safely.

Two paths from here, depending on which job you’re going after:

  • Network engineers. Examples 12 to 16 are your world. CCIE Automation takes them further, into multi-vendor estates and validated rollouts, with live sessions and an instructor in your class group when a run fails at 11pm.
  • Platform and DevOps engineers. Examples 17 to 20 are the starting point. The DevOps Engineer programme covers Ansible alongside Terraform, containers and CI/CD. Prepping for interviews? Our DevOps interview questions guide has 180 of them.

For self-paced practice, the SMEnode Labs DevOps workbook ships lab exercises with an EVE-NG image, so you can run these against real topologies instead of imagining them.


Sources

Ehsan Momeni

Ehsan Momeni

Senior Network Automation Engineer | NetDevOps Consultant

View Profile