home / writing / ansible-playbooks-and-automation

Ansible Playbooks and Automation

Ansible automates configuration and deployment over SSH without agents. This guide walks through inventory, playbooks, variables, handlers, roles and the idempotency habits that keep runs safe.

How Ansible works

Ansible is an automation engine for configuration management, application deployment and orchestration. It is agentless: the control node connects to managed nodes over SSH (or WinRM for Windows), copies small module programs over, runs them, and removes them. Nothing needs to be installed on the targets beyond SSH access and a Python interpreter for most Linux modules.

Everything is described declaratively in YAML. You say what state a machine should be in (a package present, a service running, a file with certain content) and Ansible's modules work out whether anything needs to change.

Behaviour is tuned through ansible.cfg. Ansible reads it from, in order, the ANSIBLE_CONFIG environment variable, the current directory, the home directory, and finally /etc/ansible/. Useful inspection commands are ansible-config list, ansible-config view and ansible-config dump.

Inventory and ad-hoc commands

The inventory lists the machines Ansible manages and groups them. It can be INI or YAML, and it can also be generated dynamically from a cloud provider through inventory plugins.

[web]
web1 ansible_host=192.0.2.10
web2 ansible_host=192.0.2.11

[db]
db1 ansible_host=192.0.2.20

[web:vars]
ansible_user=deploy
ansible_port=22

[production:children]
web
db

Groups can nest with :children, and shared settings go under :vars. Common connection variables include ansible_host, ansible_user, ansible_port, ansible_connection (ssh, winrm, local) and ansible_become for privilege escalation.

Ad-hoc commands run a single module against a host pattern without a playbook, ideal for quick checks:

ansible all -i inventory.ini -m ping
ansible web -m command -a "uptime"
ansible all --list-hosts

Playbooks, plays and modules

A playbook is a YAML file containing one or more plays. Each play maps a group of hosts to a list of tasks, and each task calls a module with arguments. Modules are the building blocks: apt, dnf/yum and pip for packages; copy, template, lineinfile and file for files; user, service and cron for system objects; command, shell and script as escape hatches.

- name: Configure web servers
  hosts: web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.apt:
        name: nginx
        state: present
        update_cache: true

    - name: Ensure nginx is running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

Run it with ansible-playbook site.yml. Before running for real, use the safety flags:

  • --syntax-check catches YAML and structure errors.
  • --check performs a dry run; add --diff to see file changes.
  • --list-tasks and --list-hosts confirm scope.
  • --limit web1 restricts the run to a subset of hosts.
  • --tags, --skip-tags and --start-at-task run part of a playbook.

ansible-lint complements these by flagging deprecated modules and habits such as using shell where a dedicated module exists.

Variables, facts and precedence

Variable names use letters, digits and underscores and cannot start with a digit. Values can be strings, numbers, booleans, lists or dictionaries, and are referenced with Jinja2 syntax such as {{ app_name }} or {{ database.port }}.

Variables can come from many places:

  • Inventory variables, group_vars/<group>.yml and host_vars/<host>.yml files.
  • vars: and vars_files: in a play, and vars_prompt for interactive input.
  • Extra vars passed with -e, which sit at the top of the precedence order.
  • register, which captures a task's result for later tasks.

As a rule of thumb, extra vars beat play vars, which beat host vars, which beat group vars. Keep defaults in group vars and override narrowly.

Facts are system details gathered at the start of a play (ansible_hostname, ansible_distribution, ansible_default_ipv4, ansible_memtotal_mb, and so on). Disable gathering with gather_facts: false when you do not need it. Magic variables such as hostvars, groups, group_names and inventory_hostname expose inventory context and cannot be overridden. Custom facts can be dropped as JSON or INI files with a .fact extension under /etc/ansible/facts.d/ on a managed host; they appear under ansible_local.

Loops, conditionals and handlers

- name: Install packages
  ansible.builtin.apt:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - git
  when: ansible_facts['os_family'] == 'Debian'

loop repeats a task over a list (older playbooks use with_items); the dict2items filter lets you iterate over a dictionary. when takes a Jinja2 expression without braces and can test facts, variables or a registered result such as result.rc != 0.

Handlers

Handlers are tasks that run only when notified, and only once no matter how many tasks notified them. By default they run after each section of the play (such as tasks) finishes, or earlier with meta: flush_handlers. They are the standard way to restart a service after its configuration changes.

tasks:
  - name: Deploy nginx config
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Restart nginx

handlers:
  - name: Restart nginx
    ansible.builtin.service:
      name: nginx
      state: restarted

A handler fires only if the notifying task reported changed, so an unchanged config causes no restart.

Roles and reuse

A role packages tasks, handlers, templates, files, defaults and variables into a standard directory layout so they can be shared between playbooks. Create a skeleton with ansible-galaxy init myrole.

DirectoryPurpose
tasks/Main task list, starting at main.yml
handlers/Handlers notified by the role's tasks
defaults/Low-precedence, easily overridden variables
vars/Higher-precedence role variables
templates/Jinja2 templates used by the template module
files/Static files for copy
meta/Role metadata and dependencies

Apply roles from a play with roles: [common, nginx], passing variables per role if needed. Put tunable settings in defaults/ so callers can override them without editing the role. Roles from the community can be installed with ansible-galaxy using a requirements file.

Templates use Jinja2 for variables, filters, conditionals and loops, so one template can render a config for many hosts. Other plugin types (lookup, filter, test, callback, connection and inventory plugins) extend what playbooks can do, for example reading an environment variable with lookup('env', 'HOME').

Idempotency and safe runs

An operation is idempotent when running it repeatedly leaves the system in the same state as running it once. Most modules are built this way: state: present installs a package only if missing, and copy rewrites a file only if its content differs. The run summary reports ok for no change and changed when something was altered; a second run of a healthy playbook should report zero changes.

command and shell are not idempotent by themselves and report changed every time. Prefer a purpose-built module, or guard the task with creates:, removes: or a changed_when condition.

Best practices and pitfalls

  • Name every play and task; names appear in output and make --start-at-task usable.
  • Use fully qualified module names such as ansible.builtin.copy.
  • Never store passwords or private keys in plain text in playbooks or inventory. Encrypt secrets with ansible-vault and keep them in separate variable files.
  • Prefer SSH keys over passwords, and keep host_key_checking enabled outside of throwaway labs.
  • Limit become to the tasks or plays that need root.
  • Keep playbooks small and move logic into roles; pin role and collection versions.
  • Test with --check --diff, lint in CI, and roll out with --limit before touching every host.