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:

  • Idempotenceapt: state=present only installs if missing. Re-running the playbook on a fully-configured host shows ok on 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_ec2 plugin) — 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 dest isn'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 in group_vars/, and inventory-level overrides in inventory. Avoid --extra-vars except 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.