Install
# On the control node only — managed nodes need nothing
sudo apt install ansible
# Or via pipx / uv (see /tutorials/uv-fast-python-toolchain.html)
uv tool install ansible-core
ansible --version
Verify SSH access to a managed host:
ssh user@host.example.com 'python3 --version'
If both connect and have Python 3, Ansible can run there.
Inventory
An inventory lists hosts and groups. INI or YAML; pick YAML for anything non-trivial:
# inventory.yml
all:
vars:
ansible_user: amir
ansible_python_interpreter: /usr/bin/python3
children:
web:
hosts:
web-01.lab: { ansible_host: 10.0.1.10 }
web-02.lab: { ansible_host: 10.0.1.11 }
db:
hosts:
db-01.lab: { ansible_host: 10.0.2.10 }
db-02.lab: { ansible_host: 10.0.2.11 }
prod:
children:
web:
db:
edge:
vars:
ansible_ssh_common_args: '-o ProxyJump=bastion.lab'
hosts:
edge-01.lab: { ansible_host: 10.0.5.10 }
Hosts can be in multiple groups. Group-vars and host-vars live in group_vars/<group>.yml and host_vars/<host>.yml — auto-loaded.
Ad-hoc commands
Before playbooks, the ad-hoc ansible command runs a single module against a target. Useful for one-off operations or testing connectivity:
# Ping every host (the ansible-ping module, not ICMP)
ansible -i inventory.yml all -m ping
# Restart nginx on every web host
ansible -i inventory.yml web -m systemd -a "name=nginx state=restarted" --become
# Run a shell command (last resort — prefer a real module)
ansible -i inventory.yml all -m shell -a "uptime"
# Gather all facts
ansible -i inventory.yml web-01.lab -m setup
The first playbook
# site.yml
- name: Configure web servers
hosts: web
become: true # run with sudo
gather_facts: true
vars:
nginx_version: latest
tasks:
- name: Install nginx
apt:
name: nginx
state: "{{ nginx_version }}"
update_cache: true
cache_valid_time: 3600
- name: Deploy site config
template:
src: templates/site.conf.j2
dest: /etc/nginx/sites-available/site.conf
mode: '0644'
notify: reload nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/site.conf
dest: /etc/nginx/sites-enabled/site.conf
state: link
notify: reload nginx
- name: Ensure nginx is running
systemd:
name: nginx
state: started
enabled: true
handlers:
- name: reload nginx
systemd:
name: nginx
state: reloaded
Run it:
ansible-playbook -i inventory.yml site.yml --diff --check # dry run with diff
ansible-playbook -i inventory.yml site.yml --diff # apply with diff
ansible-playbook -i inventory.yml site.yml --limit web-01.lab # only one host
Key concepts visible here:
- Idempotence —
apt: state=presentonly installs if missing. Re-running the playbook on a fully-configured host showsokon every task, no changes. - Handlers — called only when a task
notifys them, and only once at the end of the play regardless of how many tasks notified. - Templates — Jinja2-rendered files.
{{ inventory_hostname }},{{ ansible_default_ipv4.address }}, plus any vars are interpolated. --check --diff— the dry-run mode. Shows what would change. Ansible's "what's drift?" command.
Roles
For real fleets, factor playbook content into roles — reusable bundles of tasks + templates + handlers + defaults:
roles/
nginx/
defaults/main.yml # default variable values
files/ # static files to copy
handlers/main.yml
tasks/main.yml # the entry point
templates/ # Jinja2 templates
vars/main.yml # role-internal vars
meta/main.yml # dependencies, role metadata
Use a role from a playbook:
- name: Configure web servers
hosts: web
become: true
roles:
- { role: common, tags: ['common'] }
- { role: nginx, tags: ['nginx'], vars: { nginx_worker_processes: 4 } }
- { role: site, tags: ['site'] }
Ansible Vault for secrets
# Encrypt a file
ansible-vault encrypt group_vars/prod/secrets.yml
# Edit in place (prompts for password)
ansible-vault edit group_vars/prod/secrets.yml
# Use during a run
ansible-playbook -i inventory.yml site.yml --ask-vault-pass
# Or pass the password from a script (e.g. fetched from a system keyring)
ansible-playbook -i inventory.yml site.yml --vault-password-file=~/.vault_pass
Encrypted files are AES-256 with PBKDF2 key derivation. The encrypted content is committable to Git; decryption requires the password.
Dynamic inventories
Hand-maintained inventory doesn't scale. Plug into:
- AWS (
aws_ec2plugin) — list EC2 instances filtered by tag. - NetBox (see that tutorial) — use NetBox as the source of truth, query via the netbox.netbox collection.
- Proxmox, vSphere, Hetzner, DO — native plugins for each.
The inventory_plugins: setting in ansible.cfg + a small YAML config file replaces the static inventory.
ansible.cfg
# ./ansible.cfg in the project directory
[defaults]
inventory = ./inventory.yml
roles_path = ./roles
collections_path = ./collections
host_key_checking = false # for ephemeral test infra; never for prod
forks = 25 # parallel ssh connections
gathering = smart
retry_files_enabled = false
stdout_callback = yaml
[ssh_connection]
pipelining = true # major speedup on most workloads
control_path = ~/.ssh/cm/%%C
ssh_args = -o ControlMaster=auto -o ControlPersist=30m
ControlMaster + pipelining cut a typical run's wall-clock time roughly in half on a moderate fleet.
Testing playbooks: molecule
For role-level testing, the molecule framework spins up a container (or VM) per scenario, applies the role, runs idempotence checks (the second run should be all ok), and runs verification steps. Standard for any role that's reused widely.
What goes wrong
- Using shell when a module exists.
shell: cp file destisn't idempotent;copy:is. The dozen most common operations (file management, packages, services, users, cron, sysctl) all have purpose-built modules. - Long playbooks that do everything. Split into roles; one role per service. Compose at the playbook level.
- Pulling vars out of thin air at runtime. Variable precedence is documented but subtle; declare defaults in
roles/<name>/defaults/, group-level overrides ingroup_vars/, and inventory-level overrides in inventory. Avoid--extra-varsexcept for one-shot operations. - Forgetting
--check --diff. Dry-run + diff before applying is the cheap insurance.
Ansible vs alternatives
- vs Puppet/Chef — Ansible is agentless and YAML-based; the others have a per-node agent and a DSL. For new projects in 2026, Ansible wins on simplicity; for existing Puppet/Chef shops, stay.
- vs SaltStack — Salt has an agent (minion) by default and is faster at scale, but more operationally heavy.
- vs Terraform / OpenTofu (see that tutorial) — complementary. Terraform/OpenTofu provision infrastructure (create the VM); Ansible configures inside the VM (install services, drop config). Use both.
- vs container-native config — if every workload runs in a container, the Dockerfile + Kubernetes manifests replace most of what Ansible would do at the host layer. Ansible remains useful for the underlying hosts themselves, plus anything that isn't containerized.