Ansible

Ansible Automation Guide: From Basics to Production

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.

Original content from computingforgeeks.com - post 165310

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:

PieceWhat it isWhere it lives
InventoryHosts, groups and their variablesINI or YAML file, or a plugin that queries an API
PlaybookOrdered plays that map host groups to tasksYAML in your project
ModuleThe unit of work (package, file, service)Collections such as ansible.builtin
FactsOS, network and hardware details gathered per hostCollected at the start of each play
HandlerA task that runs only when notified by a changeUnder 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:

Sourceansible-coreansible (full bundle)How we checked
pipx or pip from PyPI2.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 apt2.20.113.1.0apt-cache policy on the lab host
Debian 13 apt2.19.1112.0.0apt-cache policy on the Debian host
Rocky Linux 10 AppStream2.16.16not packagedRepository 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:

Ansible ad-hoc setup facts and an idempotent playbook run against Ubuntu 26.04 and Debian 13 hosts

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:

StepGuideWhat you can do after it
1Inventory managementGroup hosts and set per-group variables
2Variables and factsMake one playbook fit several environments
3Conditionals and loopsBranch on facts such as the OS family
4RolesPackage tasks, templates and handlers for reuse
5VaultCommit encrypted secrets next to the code
6CollectionsInstall and pin modules beyond ansible.builtin
7FiltersReshape API output and variables in templates
8Dynamic inventoryBuild the host list from a cloud or hypervisor API
9MoleculeTest 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:

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:

SettingRunsMean wall timeRange
ANSIBLE_PIPELINING=False (default)55.18 s5.16 to 5.19 s
ANSIBLE_PIPELINING=True54.74 s4.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 -o flag and oneline callback, now deprecated
  • the paramiko connection plugin, removed
  • the required option of get_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.

Keep reading

OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) AI OpenCode CLI Cheat Sheet: Commands, Sessions and Fixes (PDF) What Is DeepSeek Harness? Install dsh and Run Your First Agent AI What Is DeepSeek Harness? Install dsh and Run Your First Agent DeepSeek Harness With Ollama: Local Model Setup, vLLM, llama.cpp AI DeepSeek Harness With Ollama: Local Model Setup, vLLM, llama.cpp Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Automation Best Weather Stations for Home Assistant: Ecowitt, Tempest, Davis Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant Automation Best Water Leak Sensors and Auto Shutoff Valves for Home Assistant How To Install Chef Workstation on CentOS 8 / RHEL 8 Automation How To Install Chef Workstation on CentOS 8 / RHEL 8

Leave a Comment

Press ESC to close