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-checkcatches YAML and structure errors.--checkperforms a dry run; add--diffto see file changes.--list-tasksand--list-hostsconfirm scope.--limit web1restricts the run to a subset of hosts.--tags,--skip-tagsand--start-at-taskrun 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>.ymlandhost_vars/<host>.ymlfiles. vars:andvars_files:in a play, andvars_promptfor 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.
| Directory | Purpose |
|---|---|
| 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-taskusable. - 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-vaultand keep them in separate variable files. - Prefer SSH keys over passwords, and keep
host_key_checkingenabled outside of throwaway labs. - Limit
becometo 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--limitbefore touching every host.