Skip to content

Introduction to Ansible

Once you manage more than a handful of servers, running commands by hand on each one stops scaling. Ansible lets you describe the desired state of many machines in plain YAML and apply it consistently. This page takes you from install to your first real playbook.

Tested on

Control node: AlmaLinux 9.4 with ansible-core 2.16. Managed nodes: AlmaLinux 9 and Ubuntu 22.04, reached over SSH.

What Ansible is

Ansible is a configuration-management and automation tool with a few defining traits:

  • Agentless — nothing to install on the managed machines. It connects over plain SSH (and uses Python, which servers already have).
  • Push model — you run Ansible from a control node and it pushes changes out to managed nodes. There is no central server polling clients.
  • Idempotent — modules describe a desired state, not a sequence of commands. Running a playbook twice makes no further changes the second time if nothing has drifted.
  • Declarative + readable — playbooks are YAML, so they double as documentation of how a system is built.

For sysadmins this means repeatable server builds, drift correction, and changes applied to 1 or 1000 hosts the same way — without maintaining agents.

Installing Ansible

You only install Ansible on the control node.

# AlmaLinux / RHEL 9 (EPEL provides the package)
sudo dnf install -y epel-release
sudo dnf install -y ansible-core

# Debian / Ubuntu
sudo apt update
sudo apt install -y ansible

# Any platform, latest version, isolated in a venv
python3 -m pip install --user ansible

# Confirm
ansible --version
ansible [core 2.16.3]
  config file = /etc/ansible/ansible.cfg
  python version = 3.9.18

The only requirement on managed nodes is SSH access (ideally key-based) and Python 3.

The inventory file

The inventory lists the hosts Ansible manages and groups them. The classic format is INI.

# inventory.ini
[web]
web01.example.com
web02.example.com

[db]
db01.example.com ansible_host=10.0.0.21

[all:vars]
ansible_user=deploy
ansible_ssh_private_key_file=~/.ssh/id_ed25519
  • [web] and [db] are groups you can target as a unit.
  • ansible_host= lets a friendly name map to a real IP.
  • Per-host or per-group connection settings go inline or in an [group:vars] block.

The same inventory in YAML:

# inventory.yml
all:
  vars:
    ansible_user: deploy
  children:
    web:
      hosts:
        web01.example.com:
        web02.example.com:
    db:
      hosts:
        db01.example.com:
          ansible_host: 10.0.0.21

Point Ansible at an inventory with -i:

ansible -i inventory.ini all --list-hosts

Ad-hoc commands

Ad-hoc commands run a single module against hosts without writing a playbook — great for quick checks and one-offs. The shape is ansible <pattern> -m <module> -a "<args>".

# Connectivity test — the 'ping' module checks SSH + Python, not ICMP
ansible all -i inventory.ini -m ping

# Run an arbitrary command (the 'command' module — no shell features)
ansible web -i inventory.ini -m command -a "uptime"

# Install a package (needs privilege escalation)
ansible web -i inventory.ini -m dnf -a "name=htop state=present" --become

# Manage a service
ansible web -i inventory.ini -m service -a "name=nginx state=started enabled=true" --become
web01.example.com | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

command vs shell

The command module does not invoke a shell, so pipes, redirects, and $VARS don't work — which is safer. Use the shell module only when you genuinely need shell features.

Playbooks

A playbook is a YAML file describing one or more plays. Each play maps a group of hosts to an ordered list of tasks, where each task calls a module.

Here is a complete, runnable playbook that installs nginx, deploys a config, starts it, and opens the firewall:

# webserver.yml
---
- name: Configure web servers
  hosts: web
  become: true                # run tasks with sudo

  vars:
    http_port: 80
    site_root: /usr/share/nginx/html

  tasks:
    - name: Install nginx
      ansible.builtin.dnf:
        name: nginx
        state: present

    - name: Deploy the landing page
      ansible.builtin.copy:
        content: "<h1>Managed by Ansible on {{ inventory_hostname }}</h1>\n"
        dest: "{{ site_root }}/index.html"
        mode: "0644"
      notify: Restart nginx

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

    - name: Open the HTTP port in firewalld
      ansible.posix.firewalld:
        port: "{{ http_port }}/tcp"
        permanent: true
        immediate: true
        state: enabled

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

Run it:

# Dry run first — shows what WOULD change, changes nothing
ansible-playbook -i inventory.ini webserver.yml --check

# Apply for real
ansible-playbook -i inventory.ini webserver.yml
PLAY [Configure web servers] ***************************************************

TASK [Install nginx] ***********************************************************
changed: [web01.example.com]

TASK [Deploy the landing page] *************************************************
changed: [web01.example.com]

RUNNING HANDLER [Restart nginx] ************************************************
changed: [web01.example.com]

PLAY RECAP *********************************************************************
web01.example.com  : ok=5  changed=3  unreachable=0  failed=0

Module names

Modern Ansible uses fully-qualified collection names like ansible.builtin.dnf and ansible.posix.firewalld. The ansible.posix collection ships with ansible but if you installed bare ansible-core you may need ansible-galaxy collection install ansible.posix. For package management on Debian/Ubuntu nodes, swap ansible.builtin.dnf for ansible.builtin.apt (or use the generic ansible.builtin.package).

Variables and vars

Variables make playbooks reusable. They can be defined inline (as in vars: above), in the inventory, in group_vars/ and host_vars/ directories, or passed at the command line with -e:

ansible-playbook -i inventory.ini webserver.yml -e "http_port=8080"

Reference a variable with Jinja2 braces: {{ http_port }}. The special variable inventory_hostname always holds the name of the host currently being configured.

Handlers — notify and restart

A handler is a task that only runs when notified by another task, and only once at the end of the play even if notified many times. This is the idiomatic way to restart a service only when its config actually changed:

tasks:
  - name: Deploy nginx config
    ansible.builtin.template:
      src: nginx.conf.j2
      dest: /etc/nginx/nginx.conf
    notify: Restart nginx     # fires the handler only if the file changed

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

If the config is already correct, the task reports ok (not changed), the handler is never notified, and nginx is left running undisturbed.

Idempotency in practice

Idempotency is the property that applying the same playbook repeatedly converges to the same state. Run the web playbook a second time:

PLAY RECAP *********************************************************************
web01.example.com  : ok=5  changed=0  unreachable=0  failed=0

changed=0 — Ansible checked each desired state, found it already satisfied, and did nothing. This is what makes it safe to run on a schedule or to fix configuration drift. It works because modules check current state before acting; this is also why you should prefer real modules (dnf, service, copy) over the command/shell modules, which Ansible cannot reason about and always reports as changed.

Roles and ansible-galaxy (brief)

As playbooks grow, roles package tasks, handlers, templates, files, and default variables into a reusable, shareable unit with a standard directory layout (tasks/, handlers/, templates/, defaults/, …).

# Scaffold a new role
ansible-galaxy role init webserver

# Install a community role from Ansible Galaxy
ansible-galaxy role install geerlingguy.nginx

You then include the role from a playbook:

- hosts: web
  become: true
  roles:
    - webserver

Roles are the right next step once a single playbook gets unwieldy — but the playbook above is enough to manage real servers today.

Verify your work

# 1. Ansible is installed
ansible --version

# 2. Inventory parses and hosts are reachable
ansible all -i inventory.ini --list-hosts
ansible all -i inventory.ini -m ping

# 3. Playbook syntax is valid
ansible-playbook -i inventory.ini webserver.yml --syntax-check

# 4. A second run is idempotent (expect changed=0)
ansible-playbook -i inventory.ini webserver.yml

# 5. The service is actually serving
ansible web -i inventory.ini -m uri -a "url=http://localhost/"

Summary

  • Ansible is agentless, SSH-based, push model, and idempotent — ideal for managing fleets of servers.
  • Install ansible-core on the control node only; managed nodes just need SSH and Python.
  • The inventory (INI or YAML) lists and groups hosts; ansible_host maps names to IPs.
  • Ad-hoc commands (-m ping, -m dnf, -m service) handle quick one-offs.
  • Playbooks declare desired state in YAML with hosts, become, tasks, and modules; use vars for reuse and handlers to restart services only on change.
  • Idempotency lets you re-run safely; roles + ansible-galaxy organize larger automation.

Related: Package management · systemd service management · Bash scripting.

Test yourself