Ansible needs nothing on the servers it manages beyond SSH and Python. That is why one control node with a text inventory can run the same change across two hosts or two hundred. The catch for anyone starting now is version drift: Ubuntu 26.04 packages ansible-core 2.20, Debian 13 ships 2.19, and upstream has released 2.22.0, one minor release after 2.21 removed APIs that some collections still call. Which install you pick decides which guides and collections work for you as written.
This page is the map of our Ansible tutorial series, from the first ad-hoc command to roles, Vault, dynamic inventory and integrations, with a link to a tested guide for each step. We built a small lab for it, a control node on Ubuntu 26.04 with ansible-core from pipx managing one Ubuntu 26.04 and one Debian 13 host, and reran the install, ad-hoc, playbook, check mode and pipelining steps on ansible-core 2.22.0 in November 2026. Every outcome matched the first run on 2.21.5 in October, so the errors, captures and timings below still come from that October run unless a block says otherwise.
Understand how Ansible runs a task
Ansible is push-based and agentless. The control node reads the inventory, builds the task list from the playbook, then opens SSH connections to the managed hosts in parallel, five at a time by default. For each task it copies a small Python module to the host, runs it, reads the JSON result and removes the module, so nothing stays resident between runs. That design is why a managed host only needs a Python interpreter, and why a slow or unreachable host shows up as UNREACHABLE instead of hanging a daemon somewhere.
These are the pieces you’ll meet in every guide in the series:
| Piece | What it is | Where it lives |
|---|---|---|
| Inventory | Hosts, groups and their variables | INI or YAML file, or a plugin that queries an API |
| Playbook | Ordered plays that map host groups to tasks | YAML in your project |
| Module | The unit of work (package, file, service) | Collections such as ansible.builtin |
| Facts | OS, network and hardware details gathered per host | Collected at the start of each play |
| Handler | A task that runs only when notified by a change | Under handlers: in the play |
Most modules are idempotent: they check the current state and act only when it differs from what you declared. Run a good playbook twice and the second recap reads changed=0, which turns every run into a drift check as well as a deployment.
Install Ansible on the control node
Install Ansible only on the machine you run playbooks from, such as your laptop, a jump host or a CI runner. The managed hosts need SSH and Python 3, which the Ubuntu 26.04 and Debian 13 cloud images in our lab already had, plus a login user that accepts your SSH key (ssh-copy-id sets that up) and can use sudo. Our lab VMs got an ansible user with passwordless sudo from cloud-init; if your sudo asks for a password, add -K to the commands below so Ansible prompts for it. What differs is the ansible-core version each source gives you:
| Source | ansible-core | ansible (full bundle) | How we checked |
|---|---|---|---|
| pipx or pip from PyPI | 2.22.0 (2.21.6 on the 2.21 line) | 14.5.0 (pins ansible-core 2.21) | ansible-core 2.22.0 installed on Ubuntu 26.04, other versions from PyPI |
| Ubuntu 26.04 apt | 2.20.1 | 13.1.0 | apt-cache policy on the lab host |
| Debian 13 apt | 2.19.11 | 12.0.0 | apt-cache policy on the Debian host |
| Rocky Linux 10 AppStream | 2.16.16 | not packaged | Repository index only, not installed |
The distro packages get security fixes from the distribution and need no extra tooling, so they suit a control node that only runs your own roles. We’d pick pipx for anything that pulls collections from Galaxy, because new collection releases target the current ansible-core and the gap to 2.16 on Rocky Linux 10 is six minor releases. pipx keeps Ansible in its own virtual environment, so it never fights the system Python. This installs ansible-core only (the engine plus the ansible.builtin modules), which is what every guide in this series assumes, and it took 23 seconds on our control node:
sudo apt install -y pipx
pipx install --include-deps ansible-core
pipx ensurepath
The --include-deps flag changes nothing for ansible-core, whose commands are its own, but it matters for the community bundle. If you want the 14.x bundle with its collections preinstalled (14.5.0 still pins ansible-core 2.21, not 2.22), install the ansible package instead of ansible-core, with the flag, because the commands live in its ansible-core dependency. In a separate test on another Ubuntu 26.04 machine, pipx install --include-deps ansible took 31 seconds and listed 91 collections, while the same install without the flag exposed only ansible-community. Pick one of the two packages, since both provide the same command names and pipx would warn that the second install’s links point to the first.
Open a new shell and confirm the version. The first line of the output is the one guides refer to:
ansible --version
Ours, from the November rerun, printed the latest core release, with the config file still unset because we had not created one yet:
ansible [core 2.22.0]
config file = None
configured module search path = ['/home/josphat/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /home/josphat/.local/share/pipx/venvs/ansible-core/lib/python3.14/site-packages/ansible
ansible collection location = /home/josphat/.ansible/collections:/usr/share/ansible/collections
executable location = /home/josphat/.local/bin/ansible
python version = 3.14.4 (main, Aug 20 2026, 10:41:58) [GCC 15.2.0] (/home/josphat/.local/share/pipx/venvs/ansible-core/bin/python)
jinja version = 3.1.6
pyyaml version = 6.0.3 (with libyaml v0.2.5)
For distribution packages and repository setup, follow the guide for your OS: Rocky Linux and Ubuntu, Debian, Ubuntu 26.04 or openSUSE Leap.
Run your first ad-hoc commands
An ad-hoc command runs one module against a host pattern without a playbook, which makes it the fastest way to prove that SSH, sudo and Python all work before you write anything bigger. Our lab inventory is a plain INI file with two hosts in a web group, and an ansible.cfg next to it points at that file so no command needs -i:
vim inventory.ini
The group variable sets the SSH user once for both hosts. Use your own host names and addresses here:
[web]
web01 ansible_host=192.168.122.31
deb01 ansible_host=192.168.122.32
[web:vars]
ansible_user=ansible
Then create the config file in the same directory. It keeps every later command short.
vim ansible.cfg
Host key checking is off only because these lab VMs are rebuilt often and get new keys each time. Leave it on for real servers, since it is what stops you from pushing config to an impostor:
[defaults]
inventory = inventory.ini
host_key_checking = False
interpreter_python = auto_silent
Start with the ping module. It does not send ICMP, it logs in and runs a tiny Python module, so a pong means the whole chain works:
ansible all -m ansible.builtin.ping
Both hosts answered, and the output shows that Ansible found a different Python on each one without being told. Debian 13 ships Python 3.13 and Ubuntu 26.04 ships 3.14:
deb01 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.13"
},
"changed": false,
"ping": "pong"
}
web01 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.14"
},
"changed": false,
"ping": "pong"
}
The capture below, taken during the October run on ansible-core 2.21.5, adds a facts query, which is how playbooks tell Debian from Ubuntu at run time, and the result of the playbook in the next section:

The ad-hoc commands guide covers file copies, package installs and parallel restarts this way. One change to know about: the -o one-line output flag has been deprecated since ansible-core 2.19 and is due to be removed in 2.23, so older snippets that use it print a deprecation warning today.
Write a playbook with a template and a handler
A playbook declares the state you want and lets the modules work out the steps. This one installs Nginx, renders a virtual host from a Jinja2 template, and reloads Nginx only when that file changes. Create it in the same directory.
vim site.yml
It is the exact file we ran against both hosts. Keep the update_cache line, because the Debian 13 cloud image could not find Nginx until the package lists were refreshed:
---
- name: Configure web servers
hosts: web
become: true
vars:
app_port: 8080
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Deploy the app virtual host
ansible.builtin.template:
src: templates/app.conf.j2
dest: /etc/nginx/conf.d/app.conf
mode: "0644"
notify: Reload Nginx
- name: Keep Nginx running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers:
- name: Reload Nginx
ansible.builtin.service:
name: nginx
state: reloaded
Facts gathered from each host feed the template, so every server describes itself. Create the directory and the file.
mkdir -p templates
vim templates/app.conf.j2
Add the server block, which listens on the port the playbook sets.
server {
listen {{ app_port }};
server_name {{ inventory_hostname }};
location / {
return 200 "{{ inventory_hostname }} runs {{ ansible_facts['distribution'] }} {{ ansible_facts['distribution_version'] }}\n";
}
}
The cache_valid_time: 3600 setting means later runs skip the refresh when the lists are less than an hour old, so a rerun stays fast and reports no change. Now run the playbook for real.
ansible-playbook site.yml
The first run took 47 seconds for both hosts. Three tasks changed on each, and the handler fired once per host after the template changed:
PLAY RECAP *********************************************************************
deb01 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web01 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Each host now answers on port 8080 with its own facts, which proves the template was rendered per host rather than copied:
curl -s http://192.168.122.31:8080/; curl -s http://192.168.122.32:8080/
Both answered with their distribution.
web01 runs Ubuntu 26.04
deb01 runs Debian 13.7
A second run reported changed=0 on both hosts and the handler stayed quiet. That is the result you want before you trust a playbook in a pipeline. The playbook tutorial, handlers and Jinja2 templates guides take each of these pieces further.
Preview changes with check mode
Check mode runs the playbook without changing anything, and --diff prints what each file would become. It is the closest thing Ansible has to terraform plan, so it belongs in code review. We previewed a port change on the configured hosts:
ansible-playbook site.yml --check --diff -e app_port=8081
Each host’s diff shows the one line that would change. The handler is reported as changed too, but it did not run, and port 8081 still did not answer afterwards:
TASK [Deploy the app virtual host] *********************************************
--- before: /etc/nginx/conf.d/app.conf
+++ after: /home/josphat/.ansible/tmp/ansible-local-42841lhb49az8/tmph027km_p/app.conf.j2
@@ -1,5 +1,5 @@
server {
- listen 8080;
+ listen 8081;
server_name deb01;
location / {
changed: [deb01]
Check mode has limits you’ll hit on fresh servers, and our very first run, before anything was installed, hit two of them. That draft of the playbook used the generic ansible.builtin.package module, and check mode never refreshes the apt cache, so on the Debian host, whose cloud image had empty package lists, the install task failed outright:
fatal: [deb01]: FAILED! => {"changed": false, "msg": "No package matching 'nginx' is available"}
On web01 the install task only pretended to succeed, so the service task that followed failed with Could not find the requested service nginx: host. Neither failure happened in the real run, which refreshed the cache through update_cache and installed the package before starting it. Tasks that depend on an earlier change, or on a cache that check mode will not touch, cannot be predicted, so use check mode on hosts that already match the playbook, which is exactly the review case. The debugging guide covers verbosity levels and the debug module for the cases check mode cannot explain.
Follow the Ansible tutorial path
The series is ordered for reading top to bottom. If you’re new, work through the fundamentals in order, because each guide builds on the inventory and playbook habits from the one before. If you already write playbooks, skip to the row that matches the problem in front of you:
| Step | Guide | What you can do after it |
|---|---|---|
| 1 | Inventory management | Group hosts and set per-group variables |
| 2 | Variables and facts | Make one playbook fit several environments |
| 3 | Conditionals and loops | Branch on facts such as the OS family |
| 4 | Roles | Package tasks, templates and handlers for reuse |
| 5 | Vault | Commit encrypted secrets next to the code |
| 6 | Collections | Install and pin modules beyond ansible.builtin |
| 7 | Filters | Reshape API output and variables in templates |
| 8 | Dynamic inventory | Build the host list from a cloud or hypervisor API |
| 9 | Molecule | Test a role in a throwaway container before release |
A book still helps with the parts tutorials skip, such as how to lay out a repository for a team or how to structure roles for reuse. If you learn better that way, our ranking of the best Ansible books notes which ones cover the current collection-based ansible-core and which still teach the older all-in-one releases.
Connect Ansible to the rest of your stack
Ansible rarely runs alone. These guides cover the integrations we’ve tested, each with its own lab:
- Terraform plus Ansible with a dynamic inventory from state
- Proxmox VMs created and configured from playbooks
- Windows Server over WinRM
- Docker containers with community.docker
- a Kubernetes cluster bootstrapped by playbooks
- Event-Driven Ansible reacting to alerts and webhooks
Most readers need the Terraform pairing first, because it settles who owns a server’s lifecycle and who owns its configuration. Full projects follow the same pattern at a larger size: a LAMP or LEMP stack, server hardening and a Grafana data source each run as one playbook against a fresh host. If you’d rather drive playbooks from a web UI with schedules and access control, AWX is the upstream of Red Hat’s automation platform, and the Ansible cheat sheet keeps the command syntax in one place.
Speed up playbook runs
Most of a run’s time on a small inventory is SSH round trips, not work on the host. Pipelining pipes the module straight into the remote Python over the open SSH session, so Ansible skips creating a remote temp directory, transferring the file and cleaning it up afterwards, which is several SSH operations per task. We timed the idempotent run of the playbook above five times each way against the two lab hosts:
| Setting | Runs | Mean wall time | Range |
|---|---|---|---|
ANSIBLE_PIPELINING=False (default) | 5 | 5.18 s | 5.16 to 5.19 s |
ANSIBLE_PIPELINING=True | 5 | 4.74 s | 4.71 to 4.78 s |
That’s about 8.5% on a four-task play over a local network, and the runs were tight enough that the gap is real. The saving grows with task count and network latency, so a 200-task role over a WAN link gains far more than our lab did. Pipelining needs sudo to work without a TTY, so any host with requiretty in its sudoers file breaks with it on. Our Ubuntu and Debian hosts worked fine, and you can enable it in ansible.cfg with pipelining = True under [connection] once a test run passes. The other big lever is forks, which defaults to 5 parallel hosts and is worth raising once your inventory outgrows that, since hosts beyond the fork count wait their turn.
Watch for breaking changes in ansible-core releases
ansible-core ships a new minor release roughly twice a year, and the 2.21 line removed things that older content still uses. These are the changes we ran into or confirmed in the changelog while rebuilding the lab:
- the
-oflag and oneline callback, now deprecated - the
paramikoconnection plugin, removed - the
requiredoption ofget_bin_path, removed
Removing that option broke a real collection. cloud.terraform 4.0.0 still passes required, so its Terraform inventory plugin fails on ansible-core 2.21 and later, and Ansible quietly falls back to an empty inventory. The Terraform and Ansible tutorial shows the error and the binary_path workaround. Before you upgrade a shared control node, read the porting guide for the target version and run your playbooks in check mode against a test host. Pin ansible-core in CI so an upgrade happens when you choose it, not when a runner image changes under you.