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
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:
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
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:
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:
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-coreon the control node only; managed nodes just need SSH and Python. - The inventory (INI or YAML) lists and groups hosts;
ansible_hostmaps 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; usevarsfor reuse and handlers to restart services only on change. - Idempotency lets you re-run safely; roles +
ansible-galaxyorganize larger automation.
Related: Package management · systemd service management · Bash scripting.