Skip to content

Writing Tasks

A task is a single unit of work — "install this package," "copy this file," "restart this service." Tasks live in a role's tasks/main.yml (or directly in a playbook) and run top to bottom. This page is the "how do I actually write Ansible" reference.

The golden rule: describe the end state, not the steps

Ansible is declarative. You don't say "run apt install nginx" — you say "nginx should be present." Ansible checks the current state and only changes what's needed. Run it twice and the second run reports changed=0. That property is called idempotency, and it's the whole point.

Anatomy of a task

- name: Install nginx            # (1) human-readable label (always add one)
  ansible.builtin.apt:           # (2) the module — the thing that does the work
    name: nginx                  # (3) module arguments
    state: present
  become: true                   # (4) run this task with sudo
  when: ansible_os_family == "Debian"   # (5) only run if this is true
  notify: restart nginx          # (6) fire a handler if this task changes anything
  tags: [web]                    # (7) label for --tags / --skip-tags
  1. name — shows in the output. Unnamed tasks are hard to read; always name them.
  2. Moduleapt, copy, template, service, etc. Use the fully qualified name (ansible.builtin.apt) — see Collections.
  3. Arguments — indented under the module.
  4. become — privilege escalation (sudo). Can be set per-task, per-play, or globally in ansible.cfg.
  5. when — a conditional (below).
  6. notify — triggers a handler.
  7. tags — for running subsets (ad-hoc & check mode).

Variables in tasks

Reference variables with {{ }}. They come from group_vars/host_vars, facts, or register.

- name: Create the app directory
  ansible.builtin.file:
    path: "{{ docker_compose_dir }}/myapp"   # value from group_vars/all.yml
    state: directory
    owner: "{{ main_user }}"
    mode: "0755"

Quote values that start with {{

YAML reads a leading { as the start of a dictionary. If a value starts with a variable, wrap the whole thing in quotes: path: "{{ x }}/sub", not path: {{ x }}/sub.

Conditionals — when

Run a task only when an expression is true. No {{ }} needed inside when — it's already a Jinja2 expression.

- name: Install a package only on Debian
  ansible.builtin.apt:
    name: htop
  when: ansible_os_family == "Debian"

- name: Multiple conditions (AND — all must be true)
  ansible.builtin.command: /opt/setup.sh
  when:
    - ansible_distribution == "Debian"
    - ansible_distribution_major_version | int >= 12

- name: OR condition
  ansible.builtin.debug:
    msg: "small box"
  when: ansible_memtotal_mb < 2048 or ansible_processor_vcpus < 2

- name: Run only if a variable is defined / not defined
  ansible.builtin.debug:
    msg: "custom path set"
  when: custom_path is defined

- name: Run based on a previous task's result
  ansible.builtin.service:
    name: docker
    state: restarted
  when: config_file.changed          # see 'register' below

Loops — loop

Repeat a task over a list. The current item is item.

- name: Install several packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop:
    - curl
    - git
    - htop

- name: Create several directories
  ansible.builtin.file:
    path: "{{ item }}"
    state: directory
  loop: "{{ borg_backup_targets }}"   # loop over a variable that holds a list

- name: Loop over a list of dictionaries
  ansible.builtin.user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
  loop:
    - { name: alice, groups: sudo }
    - { name: bob,   groups: docker }

Many modules take a list directly — no loop needed

apt, package, and others accept a list for name:, which is faster (one transaction) than looping:

- name: Install base packages (single apt transaction)
  ansible.builtin.apt:
    name: "{{ base_packages }}"
    state: present

Capturing output — register

Store a task's result in a variable to use later.

- name: Check if a file exists
  ansible.builtin.stat:
    path: /etc/borgmatic/config.yaml
  register: borg_cfg

- name: Only initialize if it doesn't exist yet
  ansible.builtin.command: borgmatic init --encryption repokey
  when: not borg_cfg.stat.exists

- name: Run a command and capture output
  ansible.builtin.command: docker --version
  register: docker_version
  changed_when: false            # a read-only command shouldn't report "changed"

- name: Show what we captured
  ansible.builtin.debug:
    msg: "{{ docker_version.stdout }}"

Common fields on a registered result: .stdout, .stdout_lines, .rc (return code), .changed, .failed, and (for stat) .stat.exists.

Controlling "changed" and "failed"

command/shell can't know whether they changed anything, so they always report changed. Tell Ansible the truth:

- name: This never changes state — just reads
  ansible.builtin.command: docker ps
  changed_when: false

- name: Only "changed" when output contains a marker
  ansible.builtin.shell: /opt/deploy.sh
  register: deploy
  changed_when: "'DEPLOYED' in deploy.stdout"

- name: Don't fail the play if this returns non-zero
  ansible.builtin.command: /opt/optional-check.sh
  register: check
  failed_when: false            # or: failed_when: check.rc not in [0, 2]

Prefer a real module over command/shell

A dedicated module (apt, copy, file, service, lineinfile) is idempotent for free and reports changed accurately. Reach for command/shell only when no module fits. See Modules.

Handlers

A handler is a task that only runs when notified, and only once at the end of the play — perfect for "restart the service, but only if its config actually changed."

# roles/base/tasks/main.yml
- name: Configure SSH daemon
  ansible.builtin.template:
    src: sshd_config.j2
    dest: /etc/ssh/sshd_config
    validate: "sshd -t -f %s"    # test the config before saving — avoids lockout
  notify: restart sshd           # fires ONLY if the file changed
# roles/base/handlers/main.yml
- name: restart sshd
  ansible.builtin.service:
    name: sshd
    state: restarted

If the template renders identical to what's already there, the task reports ok (not changed), the handler is not notified, and sshd is left alone.

Grouping tasks — block

A block groups tasks so they share directives (when, become, tags) and can have error handling — Ansible's version of try/except.

- name: Set up the app (with rollback on failure)
  block:
    - name: Deploy compose file
      ansible.builtin.template:
        src: app-compose.yml.j2
        dest: /opt/docker/app/docker-compose.yml
    - name: Start the stack
      ansible.builtin.command:
        cmd: docker compose up -d
        chdir: /opt/docker/app
  rescue:
    - name: Roll back on failure
      ansible.builtin.debug:
        msg: "Deploy failed  check the compose file"
  always:
    - name: This runs no matter what
      ansible.builtin.command: docker ps
  become: true                   # applies to every task in the block

Putting it together

A realistic task file reads like a checklist of desired states:

---
- name: Install Docker
  ansible.builtin.apt:
    name: docker-ce
    state: present

- name: Create app directory
  ansible.builtin.file:
    path: "{{ docker_compose_dir }}/myapp"
    state: directory
    owner: "{{ main_user }}"

- name: Deploy compose file
  ansible.builtin.template:
    src: myapp-compose.yml.j2
    dest: "{{ docker_compose_dir }}/myapp/docker-compose.yml"
  register: compose_file

- name: Start the stack only if the compose file changed
  ansible.builtin.command:
    cmd: docker compose up -d
    chdir: "{{ docker_compose_dir }}/myapp"
  when: compose_file.changed

Next

Ref: Ansible — Tasks & Playbooks