Skip to content
Back to student guides
AnsibleDevOpsInfrastructure as code3 levels128 sectionsCovers ansible-core 2.21 (Ansible 14)

The Complete Ansible Guide

Automate server configuration and deployments with Ansible playbooks. Taught at three levels — Beginner, Mid-level and Senior — each with an in-depth guide, interview prep, and practical tips.

Official docs AI-drafted · community review in progressHelp review it
26sections
89examples

This is part one of three. It covers everything you need to do real work with Ansible, not a teaser. By the end you can describe a server's desired state in a YAML file, run it against one machine or fifty, see exactly what changed, run it again and watch nothing change, keep a password out of your repository, and read the error messages when something goes wrong. Mid-level and Senior take the same topics further; nothing here is thrown away.

This guide targets ansible-core 2.21, which is the engine inside the Ansible community package 14. Where an older tutorial you find online disagrees with what you read here, trust this page, and the "Common errors" section explains the most frequent differences.

Each section ends with a Try it task. Do them as you go. They take a few minutes each, and Ansible only makes sense once you have watched your own playbook change something, run again, and report that there was nothing left to do.

What Ansible is, and the problem it solves

Ansible is a tool that configures machines from a description you write in text files. You list the machines you care about, you write down what state each one should be in (this package installed, this file present with this content, this service running), and Ansible connects to each machine and makes it so.

INVENTORYwhich machines
→
PLAYBOOKwhat state
→
MODULEShow to reach it
→
CONVERGEDevery machine matches

The diagram is the whole idea. Compare it to what came before.

Before tools like this, a new server was set up by a person following a wiki page, or by a shell script someone wrote three years ago and nobody dared touch. Both approaches share a flaw: they describe steps, not state. A script that says "append this line to the config file" is correct the first time and wrong the second, because now the line is there twice. So scripts grow if statements to check whether each step was already done, the checks get subtly wrong, and eventually every server in the fleet is slightly different from every other one. Engineers call this configuration drift, and it is the reason the phrase "nobody knows how this server was built" exists.

Ansible's answer is that you write the desired end state and each building block (called a module) is responsible for checking whether the machine is already in that state. If it is, the module does nothing and reports ok. If it is not, the module fixes it and reports changed. That property is called idempotency, and it is the single most important idea in this guide. It means you can run the same playbook every day, against a machine that is brand new or a machine that has run for five years, and the result is the same.

Three consequences of the design explain most of what follows. Notice them now rather than discovering them later.

The description is a file you commit. Your playbooks are plain YAML that sit next to your application code, so they are versioned, reviewed and diffed like any other code. When a server behaves strangely, git log tells you who changed its configuration and why.

There is nothing to install on the machines you manage. Ansible connects over SSH, the same way you log in by hand, and uses the Python that is already on the target. There is no agent to deploy, patch, secure or monitor. This is why people call Ansible agentless, and it is why you can start using it on a server that already exists today without any preparation beyond SSH access.

It runs from wherever you are. The machine you type ansible-playbook on is called the control node, and it can be your laptop. Nothing runs permanently anywhere. When the run finishes, Ansible has left no daemon behind.

What people use it for:

🖥️

Server setup

Packages, users, SSH keys, firewall rules and services, identical on every machine in the group.

🚀

Application deployment

Copy a release, write its config, restart the service, check that it answers.

🔁

Rolling changes

Patch or reconfigure a fleet a few machines at a time, stopping if something fails.

🧩

Glue between tools

Terraform creates machines, Ansible configures them, and Kubernetes runs the workloads on top.

You need little to follow along: a terminal on Linux or macOS (Windows users can use WSL, covered in the install section) and Python. You do not need a server. The first half of this guide runs entirely against your own machine, and the later sections show what changes when the target is a real remote host.

Try it
  1. Think of a server you have set up by hand: a web server, a database, a build machine.
  2. Write down each thing you did to it as a sentence, for example "installed nginx" or "created the deploy user".
  3. Rewrite each sentence as a state rather than an action: "nginx is installed", "the deploy user exists".
a list of states. Every line you wrote maps onto one Ansible task, and the exercise of rewording actions as states is the mental shift this whole tool asks of you.

The agentless model: control node and managed nodes

Two words describe every Ansible setup, and getting them clear early saves confusion later.

The control node is the machine where Ansible is installed and where you run commands. It must be Linux, macOS, or a BSD. Native Windows cannot be a control node; Windows users run Ansible inside WSL (the Windows Subsystem for Linux), a virtual machine, or a container. The managed nodes are the machines Ansible configures. You will also see them called hosts or targets. They need no Ansible software at all.

What a managed node does need is a way in and a Python interpreter. For Linux and other POSIX machines that means SSH access and Python 3.9 or newer (for ansible-core 2.21, Python 3.9 through 3.14 on the target; the control node needs 3.12 through 3.14). Windows managed nodes need PowerShell and are reached over WinRM, PSRP or SSH instead. A handful of modules (raw and script) work even without Python, which matters when you need to install Python itself on a bare machine.

Here is what actually happens when a task runs, because it explains both the speed characteristics and several error messages.

  1. Ansible reads your playbook and inventory on the control node.
  2. For each task, it takes the module (a small piece of Python code) and packages it with its arguments.
  3. It copies that package to the target over the connection, which is SSH by default, into a temporary directory.
  4. It runs the package on the target using the target's Python interpreter.
  5. The module checks the current state, changes what needs changing, and prints a JSON result.
  6. Ansible reads the JSON, reports ok, changed or failed, and deletes the temporary files.

That is called a push model: the control node pushes work out. The opposite, where each machine pulls its instructions from a central place on a schedule, also exists (ansible-pull), but beginners should start with push.

Some modules run on the control node Not everything executes on the target. A few modules, such as template, debug and set_fact, are built on controller-side helpers, and any task can be sent elsewhere with delegate_to. When a beginner says "it ran on the wrong machine", this is usually why. For now, assume the module runs where the play says it should.

Because the target's Python runs the module, Ansible has to find that Python. By default it does this automatically (interpreter_python = auto) and sometimes prints a warning that it found one and a different one could appear later. That warning is harmless and we will silence it in the configuration section. The older discovery modes auto_legacy and auto_legacy_silent were removed in ansible-core 2.21, so if you see them in an old blog post, skip that advice.

Try it
  1. Decide which machine will be your control node. Write down its operating system.
  2. If it is Windows, plan to use WSL with an Ubuntu distribution, and do the rest of this guide inside that Linux shell.
  3. Run python3 --version on that machine.
Python 3.12 or newer on the control node. If it is older (RHEL 9 ships 3.9 by default, for instance), note it now: the install section shows how to get a newer interpreter side by side with the old one.

The four nouns: inventory, module, task, playbook

Ansible has a lot of vocabulary, but a beginner needs four words, and every other term hangs off them.

Inventory Module Task Playbook
Is The list of machines and groups A unit of work with a fixed job One call to a module with arguments A YAML file listing plays
Analogy An address book A tool in a toolbox One line on a checklist The whole checklist
Example web01, db01, group prod ansible.builtin.copy "Copy nginx.conf into place" site.yml
Lives in inventory.ini Ships with Ansible or a collection Inside a play Your repository

Two more terms appear constantly. A play connects a group of hosts to a list of tasks: "on these machines, do these things". A playbook contains one or more plays, which is why a playbook can first prepare the database servers and then configure the web servers in a single file. Handlers, which you will meet later, are tasks that run only when something changed.

Modules deserve a closer look, because the quality of your Ansible code is mostly the quality of your choice of modules. A module is a small program with one job: ansible.builtin.user manages user accounts, ansible.builtin.file manages files and directories, ansible.builtin.dnf installs packages on Fedora and Red Hat systems. Each module takes arguments (the user's name, the file's permissions), knows how to check the current state, and knows how to change it. Hundreds of modules exist. You almost never write your own as a beginner; you pick the right one from the documentation.

The long name matters. ansible.builtin.copy is a fully qualified collection name, abbreviated FQCN. It has three parts: a namespace (ansible), a collection (builtin) and the module (copy). Collections are how Ansible distributes modules: ansible.builtin ships with the engine, and others such as community.general or amazon.aws are installed separately. Older tutorials write just copy, which still works, but writing the full name is now the standard. It removes any doubt about which copy you mean, and linters expect it. This guide uses FQCNs everywhere.

You can read any module's manual offline Run ansible-doc ansible.builtin.copy in your terminal. It prints every argument, its default, and worked examples at the bottom, using exactly the version you have installed. ansible-doc -l lists every module. Reaching for this before a search engine will save you from tutorials written for a different version.
Try it
  1. Once Ansible is installed (next section), run ansible-doc ansible.builtin.file.
  2. Find the argument named state and list its allowed values.
  3. Scroll to the EXAMPLES section and read the first three.
values such as directory, touch, absent and link. One module, several states: that is the pattern you will see in almost every module you use.

Installing Ansible and checking the setup

The community package, called simply ansible, contains ansible-core plus a large set of curated collections. The smaller package, ansible-core, contains the engine and the ansible.builtin collection only. As a beginner, install the full ansible package, because it saves you from installing collections one at a time later. Both need Python 3.12 or newer on the control node.

The officially recommended installer on Linux is pipx, which gives each Python tool its own isolated environment so it cannot break your system Python.

BASH
pipx install --include-deps ansible       # the full community package
pipx install ansible-core==2.21.4         # or: only the engine, pinned
pipx upgrade --include-injected ansible   # later, to upgrade

If you prefer a plain virtual environment, which is easy to delete and recreate, this works on any system with Python 3.12 or newer:

BASH
python3 -m venv ~/.venvs/ansible
~/.venvs/ansible/bin/pip install ansible
export PATH="$HOME/.venvs/ansible/bin:$PATH"

Modern Linux distributions refuse pip install into the system Python and print externally-managed-environment. That is a deliberate protection (PEP 668), and the virtual environment or pipx is the correct answer, not a workaround flag. Distribution packages also exist and are fine for a first look, though they may lag behind:

BASH
sudo dnf install ansible-core        # Fedora, RHEL family (full ansible on Fedora)
sudo apt install ansible             # Ubuntu, after adding the Ansible PPA
sudo pacman -S ansible               # Arch
brew install ansible                 # macOS with Homebrew

On macOS, Homebrew's ansible formula tracks the community package and works well. On Windows, open an Ubuntu shell in WSL and follow the Linux instructions. A version of this problem you may meet later is managing Windows machines from Linux; that uses the winrm, psrp or ssh connection types and modules named win_*, which a later guide covers.

Now verify. Three commands tell you everything that matters.

BASH
ansible --version
ansible localhost -m ansible.builtin.ping
ansible-galaxy collection list

The first prints something like this (paths and Python version will differ):

TEXT
ansible [core 2.21.4]
  config file = None
  configured module search path = ['/Users/you/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /Users/you/.venvs/ansible/lib/python3.13/site-packages/ansible
  ansible collection location = /Users/you/.ansible/collections:/usr/share/ansible/collections
  executable location = /Users/you/.venvs/ansible/bin/ansible
  python version = 3.13.9 (main, ...)
  jinja version = 3.1.6
  pyyaml version = 6.0.3 (with libyaml v0.2.5)

Read it line by line, because it answers the questions you will ask when something breaks. config file = None means no ansible.cfg was found in the directory you are in; we will create one shortly. The collection location is where Ansible looks for installed collections. The python version is the control node's interpreter, not the target's. If you installed the full package, ansible-community --version prints the community version (for example 14.4.0), which is a different number from the core version; both are real and they answer different questions.

The second command is the smallest possible end-to-end test. It asks Ansible to run the ping module against the implicit localhost:

TEXT
localhost | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

The ping module is not a network ping. It checks that Ansible can reach the host and run Python there, which is exactly what every other module needs. "changed": false tells you it did not modify anything. You may see a warning before it about the discovered Python interpreter; that is the harmless warning mentioned earlier.

Do not use sudo pip Installing Ansible with sudo pip install writes into the system Python and can break operating system tools that depend on it. Use pipx, a virtual environment, or your distribution's package instead. If an install fails with Requires-Python >=3.12, your Python is too old for current Ansible, so install a newer Python (on RHEL 9, python3.12 from AppStream) and create the virtual environment with it.
Try it
  1. Install Ansible with pipx or a virtual environment.
  2. Run ansible --version and find the line that shows which Python Ansible will use on the control node.
  3. Run ansible localhost -m ansible.builtin.ping.
a green SUCCESS with "ping": "pong". If you see a Python interpreter warning above it, that is normal for now.

A practice lab you can run anywhere

Learning configuration management usually demands a spare server, and that blocks many beginners. You do not need one. Ansible can treat your own machine as a managed node using the local connection, which skips SSH and runs modules directly. Everything in the first half of this guide uses that, working only inside a folder you create, so nothing here can damage your system.

BASH
mkdir -p ~/ansible-lab && cd ~/ansible-lab

Every command from here on assumes you are inside this folder. The first file to create is a small configuration file. Ansible reads ansible.cfg from the current directory, and keeping one per project means a teammate who clones your repository gets the same behaviour you had.

ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent

The first line says where the inventory is, so you can leave off -i from every command. The second silences the interpreter discovery warning by using the auto_silent mode (the other valid values are auto, which warns, or an explicit path). Now the inventory:

inventory.ini
[lab]
localhost ansible_connection=local

That is one group called lab containing one host called localhost, and the variable ansible_connection=local tells Ansible to run modules directly instead of through SSH. Check that Ansible agrees with you:

BASH
ansible-inventory --graph
TEXT
@all:
  |--@ungrouped:
  |--@lab:
  |  |--localhost

@all is a group that always exists and contains every host. @ungrouped holds hosts that belong to no group, and yours is in lab. Finally, check the lab works:

BASH
ansible lab -m ansible.builtin.ping

If you see SUCCESS, the lab is ready. You now have a fully working Ansible project in three small files.

A note on the implicit localhost When no inventory matches, Ansible still offers a built-in localhost, which is why ansible localhost -m ping worked earlier. But that implicit host is not a member of all, and Ansible warns that it does not match. Defining localhost in your inventory, as above, avoids the confusion.

If you do have a spare machine, such as a cloud virtual machine, a Raspberry Pi or a virtual machine on your laptop, the later sections on real servers show the two changes needed: an address in the inventory and SSH access. Everything else in this guide is identical.

Try it
  1. Create ~/ansible-lab with the ansible.cfg and inventory.ini above.
  2. Run ansible-inventory --graph and confirm localhost sits under @lab.
  3. Run ansible --version again from inside the folder.
the config file line now shows the path to your ansible.cfg instead of None. That is how you confirm Ansible is reading the project's settings and not somebody else's.

Inventory: telling Ansible about your machines

The inventory answers "which machines?" and it carries the facts that are specific to each machine: its address, the user to log in as, the port. The simplest format is INI, which is what you just used, and the other common format is YAML. Both describe the same thing.

inventory.ini
[web]
web01 ansible_host=10.0.0.11
web02 ansible_host=10.0.0.12

[db]
db01 ansible_host=10.0.0.21 ansible_user=admin ansible_port=2222

[prod:children]
web
db

[web:vars]
http_port=80

Every line deserves a reading. A name on its own (web01) is the inventory name, the label you use in commands and playbooks. ansible_host is the real address to connect to, which lets you use friendly names while connecting by IP. ansible_user and ansible_port override the login user and the SSH port for that machine. A section in square brackets starts a group. A section named [prod:children] makes prod a group whose members are other groups, so prod contains everything in web and db. A section named [web:vars] sets variables for every host in web. Ranges save typing: web[01:10].example.com expands to ten hosts.

The same inventory in YAML:

inventory.yml
all:
  children:
    web:
      hosts:
        web01:
          ansible_host: 10.0.0.11
        web02:
          ansible_host: 10.0.0.12
      vars:
        http_port: 80
    db:
      hosts:
        db01:
          ansible_host: 10.0.0.21
          ansible_user: admin
          ansible_port: 2222

Pick one style and stay with it; INI is shorter for tiny inventories and YAML is easier once variables get structured. As a project grows, per-group and per-host variables move out of the inventory file into folders named group_vars/ and host_vars/ next to it, a pattern the variables section explains.

Once you have an inventory, you select hosts from it with patterns. A pattern appears in a playbook's hosts: line and in the --limit option.

Pattern Selects
all Every host in the inventory
web The hosts in group web
web:db Hosts in web or db
web:&prod Hosts in both web and prod
web:!staging Hosts in web except those in staging
web01 One host by name

Because the pattern language is small and powerful, always check what a pattern selects before running anything that changes state.

BASH
ansible-inventory --graph                 # the tree of groups and hosts
ansible-inventory --host web01            # the variables Ansible will use for one host
ansible web:!db --list-hosts              # what a pattern matches, without running anything
An empty pattern does not fail loudly If you mistype a group name, Ansible prints [WARNING]: Could not match supplied host pattern, ignoring: webb and then reports skipping: no hosts matched. In a script or a CI pipeline, a run that touched nothing can look like a success. Always look for that warning, and run --list-hosts first when a pattern is new.

Two other warnings tell you the inventory itself was not found: No inventory was parsed, only implicit localhost is available means you forgot -i and there is no inventory = line in ansible.cfg. The fix is one of those two.

Try it
  1. Extend your lab inventory with a second group [app] that also contains localhost ansible_connection=local, and a parent group [everything:children] listing lab and app.
  2. Run ansible-inventory --graph.
  3. Run ansible everything --list-hosts and ansible lab:&app --list-hosts.
the tree shows localhost under both groups. The union and the intersection both list it, but only once: Ansible de-duplicates hosts, so a machine in two groups is still one machine.

Ad hoc commands: one task, right now

Before playbooks, there is the quickest way to use Ansible: a single module run from the command line. These are called ad hoc commands, and they are what you use to check something across many machines or to do a one-off job you will never repeat.

The shape is always the same:

BASH
ansible <host-pattern> -m <module> -a "<arguments>"
BASH
ansible lab -m ansible.builtin.ping
ansible lab -m ansible.builtin.command -a "uptime"
ansible lab -m ansible.builtin.setup -a "filter=ansible_distribution*"
ansible lab -m ansible.builtin.file -a "path=/tmp/ansible-lab-demo state=directory mode=0755"

The command module runs a command and shows you its output. The setup module gathers facts, which are data Ansible discovers about a host (the operating system, the IP addresses, the memory), and the filter argument narrows the output to names that match. Facts get their own section later. The file module with state=directory creates a directory.

Run that last command twice and read both outputs. This is the most important thirty seconds of your first day.

TEXT
localhost | CHANGED => {
    "changed": true,
    "gid": 20,
    "group": "staff",
    "mode": "0755",
    "owner": "you",
    "path": "/tmp/ansible-lab-demo",
    "size": 64,
    "state": "directory",
    "uid": 501
}

The first time you see CHANGED, in yellow if your terminal shows colours, because the directory did not exist and the module created it. The second time:

TEXT
localhost | SUCCESS => {
    "changed": false,
    "gid": 20,
    "group": "staff",
    "mode": "0755",
    "owner": "you",
    "path": "/tmp/ansible-lab-demo",
    "size": 64,
    "state": "directory",
    "uid": 501
}

SUCCESS with "changed": false. Same command, but now the module looked, found the directory already in the desired state, and did nothing. You have just watched idempotency happen. Contrast this with the command module: run ansible lab -m ansible.builtin.command -a "uptime" twice and it reports CHANGED both times, because Ansible cannot know whether an arbitrary command changed anything. The warning sign to remember: command, shell and raw are not idempotent by themselves, so prefer a purpose-built module whenever one exists.

Privilege escalation, running as root, is requested with -b (for "become"). Installing a package needs it on a real server:

BASH
ansible web -b -m ansible.builtin.dnf -a "name=httpd state=present"

That line would install the httpd package on the web group of a Fedora or RHEL-family system. Do not run it on your laptop. On Debian and Ubuntu you would use the apt module, and the generic ansible.builtin.package module picks the right one for the platform, though package names still differ between distributions.

Ad hoc is for questions, playbooks are for answers Use an ad hoc command to ask a fleet something ("what kernel are you on?", "how much free disk?") or to do a one-time emergency action. Anything you would want to do a second time belongs in a playbook, where it is written down, reviewed and repeatable.
Try it
  1. Run ansible lab -m ansible.builtin.file -a "path=/tmp/ansible-lab-demo state=directory mode=0755" twice.
  2. Compare the two outputs and find the single word that differs.
  3. Now run ansible lab -m ansible.builtin.command -a "uptime" twice and compare.
the first pair flips from CHANGED to SUCCESS; the command pair says CHANGED both times. That difference is why modules exist, and why "just run a shell command" is the last resort.

Your first playbook

An ad hoc command is one task with no memory. A playbook is a file that holds many tasks in order, so the work is written down and repeatable. Playbooks are YAML, and YAML cares about indentation, so use spaces (never tabs) and two spaces per level.

Create welcome.yml in your lab folder:

welcome.yml
- name: Create a small site folder
  hosts: lab
  tasks:
    - name: Make the output directory
      ansible.builtin.file:
        path: "{{ playbook_dir }}/out"
        state: directory
        mode: "0755"

    - name: Write a welcome page
      ansible.builtin.copy:
        dest: "{{ playbook_dir }}/out/index.html"
        content: |
          <h1>Hello from Ansible</h1>
          <p>This file is managed by a playbook.</p>
        mode: "0644"

Read it from the outside in. The file is a list (every top-level line starts with - ), and each item in the list is a play. This play has a name, which is a human label printed while it runs; hosts: lab, which is a pattern selecting which machines; and tasks:, the ordered list of things to do.

Each task has a name and then a module with its arguments as an indented map. ansible.builtin.file with state: directory ensures a directory exists. ansible.builtin.copy with content: writes a file with exactly that text. The | after content: is YAML's way to start a block of literal text that keeps its line breaks. The value {{ playbook_dir }} is a variable, written inside double braces, which Ansible replaces with the folder holding the playbook. Because the value begins with {{, it must be wrapped in quotes; an unquoted value that starts with a brace is read by YAML as the start of a dictionary, and that mistake is so common it has its own error message later in this guide.

Two habits to adopt now. Give every task a name, because the name is what you read in the output when something fails at two in the morning. And quote file modes: mode: "0644" with quotes, since an unquoted 0644 is read by YAML as a number and can be silently misread.

Before running anything, ask Ansible to check your work:

BASH
ansible-playbook welcome.yml --syntax-check
ansible-playbook welcome.yml --list-tasks

--syntax-check parses the file without running it and catches broken YAML. --list-tasks prints the tasks it found. Now run it:

BASH
ansible-playbook welcome.yml
TEXT
PLAY [Create a small site folder] **********************************************

TASK [Gathering Facts] *********************************************************
ok: [localhost]

TASK [Make the output directory] ***********************************************
changed: [localhost]

TASK [Write a welcome page] ****************************************************
changed: [localhost]

PLAY RECAP *********************************************************************
localhost                  : ok=3    changed=2    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Check the result with cat out/index.html. Then run the same playbook a second time, and compare the recap: ok=3 changed=0. Nothing changed, because the directory and the file were already exactly as described. The playbook is now a statement of fact about the machine rather than a list of steps you performed once.

YAML is whitespace-sensitive A task's arguments must be indented further than the module name, and every task in the list must line up with its siblings. A single misplaced space gives errors such as mapping values are not allowed in this context. If you cannot see the problem, run --syntax-check, which points at the file, line and column, and use an editor that shows whitespace.
Try it
  1. Create welcome.yml as above and run --syntax-check.
  2. Run the playbook, then open out/index.html.
  3. Run it again. Then edit out/index.html by hand, changing one word, and run the playbook a third time.
the second run reports changed=0. The third run reports changed=1 and restores your file, because Ansible compares the file to the desired content every time. This is what "desired state" means: the playbook corrects drift, not just installs once.

Reading the output

Ansible's output looks noisy until you know its structure, and then it tells you everything. It has four layers.

A PLAY line announces which play is starting. A TASK line announces which task is starting. Under each task is one line per host giving that host's result. At the end the PLAY RECAP summarises every host on one line.

The per-host result is one of a small set of words, and learning them is worth five minutes:

Result Meaning
ok The task ran and the host was already in the desired state. Nothing changed.
changed The task ran and made a change.
failed The task ran and failed. Ansible stops running tasks on that host.
unreachable Ansible could not connect to the host at all (SSH, network, credentials).
skipped A condition (when) was false, so the task did not run.
rescued A task failed but a rescue block handled it.
ignored A task failed but ignore_errors let the play continue.

The recap columns count those outcomes per host. The numbers you check first are failed and unreachable: both should be zero. A healthy first run shows changed greater than zero, and a healthy second run, with nothing in the world altered, shows changed=0. Developing the reflex of reading the recap before anything else is a real productivity gain.

When something fails, the task prints fatal: and a JSON blob. Since ansible-core 2.19 it also prints an [ERROR] line naming the file, line and column, with an excerpt of your source and a <<< caused by >>> chain that walks from the symptom to the root cause. Read that chain from the bottom up. The wording of these messages is not guaranteed to stay the same between versions, so search for the meaning, not the exact text.

Increase detail with the verbosity flag. One -v shows each task's returned data; -vvv shows the connection details, including the exact ssh command line; -vvvv adds more connection debugging. You will use -v often and -vvv when you cannot connect.

BASH
ansible-playbook welcome.yml -v
ansible-playbook welcome.yml -vvv

By default the result data is printed as JSON. If you find that hard to read, ask for YAML instead in ansible.cfg:

ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
callback_result_format = yaml
Old tutorials set a yaml callback differently Many older posts tell you to set stdout_callback = yaml from the community.general collection. That plugin has been deprecated in favour of the built-in option above, so use callback_result_format = yaml on the default callback.
Try it
  1. Add callback_result_format = yaml to your ansible.cfg.
  2. Run ansible-playbook welcome.yml -v and read the data under each task.
  3. Find in that data the field that tells you whether a task changed anything.
each task prints a block with changed: true or changed: false plus module-specific details such as the path and the checksum. That changed field is what drives the coloured word and the recap counts.

Idempotency, check mode, and diff mode

You have seen idempotency; now use it deliberately. Two flags turn it into a safety tool, and using them before every real run is the habit that separates careful Ansible users from the rest.

Check mode (--check, or -C) runs the playbook as a dry run. Each module reports what it would change without changing it. Diff mode (--diff, or -D) shows, for modules that support it such as copy, template and lineinfile, the before-and-after text of files. Together:

BASH
ansible-playbook welcome.yml --check --diff

Edit out/index.html to add a stray word, then run that command. You will see changed for the copy task and a diff showing the stray word being removed, yet the file on disk is untouched until you run the playbook for real. This lets you review a change the way you review a pull request.

Check mode has limits you should know. A task that depends on an earlier task's real effect can misreport: a task that reads a file created by a previous task sees no file in check mode. Modules that cannot predict their effect, such as command and shell, are skipped in check mode rather than guessed at. A task can be marked check_mode: false to force it to run even during a dry run (useful for read-only queries whose results later tasks need), and ansible_check_mode is a variable you can test. Treat check mode as a strong hint, not a guarantee.

The deeper lesson is how to make your own tasks idempotent when no purpose-built module exists. command and shell always report changed, which makes every run look like it changed something and ruins the recap as a health signal. Two arguments and two keywords fix that.

YAML
- name: Generate a report once
  ansible.builtin.command:
    cmd: /usr/local/bin/make-report --out /var/lib/report.txt
    creates: /var/lib/report.txt

- name: Read the kernel version (changes nothing)
  ansible.builtin.command: uname -r
  register: kernel
  changed_when: false

creates: says "if this file exists, skip the task", which makes a one-time command idempotent. removes: is the mirror image. changed_when: false tells Ansible that this task never changes anything, so it reports ok instead of changed. register: kernel saves the task's result into a variable called kernel, which a later task can read as kernel.stdout. The general rule: if you must run a command, teach Ansible how to tell whether it changed something.

Run --check --diff before every change On anything that is not a disposable lab, make it your default. It costs seconds, and the diff is frequently the moment you notice the playbook is about to overwrite something you forgot was customised.
Try it
  1. Change one word in out/index.html.
  2. Run ansible-playbook welcome.yml --check --diff and read the diff.
  3. Run cat out/index.html, then run the playbook without --check, then cat again.
after the check run your edit is still there; after the real run it is gone. The dry run changed nothing and described everything.

The modules you will use every week

There are hundreds of modules, but a beginner's daily work uses a dozen. Learn these well and you can automate most of a typical Linux server. All are in ansible.builtin, so they need no extra installation.

Job Module What it does
Install software ansible.builtin.package, dnf, apt Ensure a package is present, absent or latest
Manage services ansible.builtin.service, systemd_service Start, stop, enable at boot, restart
Files and directories ansible.builtin.file Create directories, set owner and mode, make links, delete
Put a file in place ansible.builtin.copy Copy a file or write literal content
Render a file ansible.builtin.template Fill a Jinja2 template with variables
Edit one line ansible.builtin.lineinfile Ensure a line exists (or not) in a file
Edit a block ansible.builtin.blockinfile Ensure a multi-line block exists between markers
Users and groups ansible.builtin.user, group Create accounts, set groups and shells
Download ansible.builtin.get_url, unarchive, git Fetch a file, unpack an archive, clone a repository
Schedule ansible.builtin.cron Manage cron entries
Inspect ansible.builtin.stat, find, slurp Read file metadata and contents
Debug and check ansible.builtin.debug, assert, fail Print values, verify conditions, stop with a message
HTTP ansible.builtin.uri Call a URL and check the response
Wait ansible.builtin.wait_for Wait for a port or a file
Last resort ansible.builtin.command, shell, raw, script Run arbitrary commands

Three of these deserve an example, because each shows a distinct style of idempotent editing.

YAML
- name: Ensure the deploy group exists
  ansible.builtin.group:
    name: deploy
    state: present

- name: Ensure one setting in a config file
  ansible.builtin.lineinfile:
    path: "{{ playbook_dir }}/out/app.conf"
    regexp: '^log_level='
    line: 'log_level=info'
    create: true
    mode: "0644"

- name: Make sure a scratch file is gone
  ansible.builtin.file:
    path: "{{ playbook_dir }}/out/scratch.txt"
    state: absent

lineinfile is the workhorse. The regexp finds the line that should be replaced, line is what it should become, and if no line matches, the new line is added at the end. Running it again finds the line already correct and does nothing. Without a regexp, lineinfile appends a new copy each time the text differs from any existing line, so always give a regexp when you are managing a setting rather than adding a fixed line.

When you choose between shell and command, choose command. It runs the program directly, with no shell involved, so there is no pipe, redirect, glob or &&, which is also why it is safer with untrusted values. Use shell only when you truly need shell features, and then quote any variable you interpolate.

Reach for a module before a command shell: apt-get install -y nginx works and reports changed on every run. ansible.builtin.apt with name: nginx, state: present reports ok when nginx is already installed, handles lock contention and updates the package cache on request. Five seconds of reading ansible-doc beats an idempotency bug in six months.
Try it
  1. Add the lineinfile task above to welcome.yml and run the playbook twice.
  2. Change line to log_level=debug and run it again.
  3. Run cat out/app.conf.
the first run creates the file, the second reports no change, and the third edits the one line in place so the file contains a single log_level=debug line rather than two.

Variables: one playbook, many situations

A playbook with hard-coded values works for one machine. Variables let the same playbook serve many, by separating what is the same (the tasks) from what differs (the values). A variable is a name whose value you use inside {{ double braces }}.

The most direct place to define variables is the play's vars: section:

vars-demo.yml
- name: Variables in action
  hosts: lab
  vars:
    app_name: demo
    app_port: 8080
    packages:
      - git
      - curl
    settings:
      log_level: info
      workers: 4
  tasks:
    - name: Show some values
      ansible.builtin.debug:
        msg: "{{ app_name }} listens on {{ app_port }}, first package is {{ packages[0] }}, workers are {{ settings.workers }}"

Variables come in the types YAML gives you: strings, numbers, booleans, lists (items introduced with - ) and dictionaries (key: value pairs). You read a list item by position, packages[0], and a dictionary value by key, settings.workers or settings['workers']. The debug module with msg: prints the result, which makes it your print statement.

Where a variable is defined decides who can see it, and there are many places. You do not need all of them on day one. The ones you will actually use are these, and the typical beginner layout is:

  • Play vars: as above, for values tied to one play.
  • group_vars/: a folder beside your inventory with a file per group, for example group_vars/web.yml holding values for every host in web. A file named group_vars/all.yml applies to every host.
  • host_vars/: the same, for one host, for example host_vars/web01.yml.
  • Inventory variables, like ansible_host=10.0.0.11 you already used.
  • Extra vars on the command line, -e "app_port=9090", or -e @vars.yml to load a whole file.

When the same name is defined in two places, the higher-priority one wins. Ansible has 22 precedence levels, but a practical summary is enough. Role defaults are the weakest (they exist to be overridden). Then inventory and group_vars, then host_vars, then play vars, then facts and registered results, and extra vars (-e) always win. That last one is a useful emergency override and a dangerous surprise, because a forgotten -e in a script silently defeats everything in your files.

BASH
ansible-playbook vars-demo.yml
ansible-playbook vars-demo.yml -e "app_port=9090"

The second command prints 9090 instead of 8080 without touching the file. To see what value a host actually gets after all that merging, ask the inventory:

BASH
ansible-inventory --host localhost

You can also define variables while the playbook runs. register: stores a task's result (you used it earlier) and ansible.builtin.set_fact creates a variable from an expression:

YAML
- name: Compute a release name
  ansible.builtin.set_fact:
    release_name: "{{ app_name }}-{{ app_port }}"

Name your variables with lowercase letters, digits and underscores, starting with a letter, and prefix them by purpose (nginx_port, not port) so they do not collide with a variable from somewhere else. Avoid names that are Ansible keywords or magic variables.

An undefined variable is a hard error Mistype {{ app_nam }} and the task fails with 'app_nam' is undefined. That is helpful, not annoying: a silent empty string would write a broken config to production. If a value is genuinely optional, say so with a default: {{ app_port | default(8080) }}.
Try it
  1. Save vars-demo.yml and run it.
  2. Run it again with -e "app_port=9090".
  3. Create group_vars/lab.yml containing app_port: 7070 and run the playbook without -e. Then run it with -e again.
with the group_vars file in place, the play's own vars still wins (play vars outrank group vars), so you keep seeing 8080; with -e you see 9090. Try deleting app_port from the play: now 7070 appears. You just walked up the precedence ladder.

Facts: what Ansible knows about each host

Before the first task of a play, Ansible quietly runs the setup module. That is the Gathering Facts task in your output. It collects dozens of facts about each host: the operating system and version, the CPU count, memory, network interfaces, mounted disks, and more. They are stored in a variable called ansible_facts, and they are how one playbook adapts to different machines.

facts-demo.yml
- name: Use facts
  hosts: lab
  tasks:
    - name: Show a few facts
      ansible.builtin.debug:
        msg: >-
          This host runs {{ ansible_facts['distribution'] }}
          {{ ansible_facts['distribution_version'] }}
          ({{ ansible_facts['os_family'] }} family)
          with {{ ansible_facts['processor_vcpus'] }} vCPUs

To explore what is available, print everything for one host, which is a large block of JSON, and narrow with a filter:

BASH
ansible lab -m ansible.builtin.setup | less
ansible lab -m ansible.builtin.setup -a "filter=ansible_os_family"

Use the dictionary form, ansible_facts['os_family'], and not the older short form ansible_os_family. Both currently work, because Ansible also injects facts as top-level variables. But that injection (the setting INJECT_FACTS_AS_VARS) is deprecated and will be switched off by default in ansible-core 2.24. Playbooks that use the short form will break then, and every tutorial from before 2.20 uses the short form, so you will copy it by accident. Start with the right habit.

Facts cost time, since gathering runs on every host at the start of every play. When a play needs none of it, turn it off:

YAML
- name: A play that needs no facts
  hosts: lab
  gather_facts: false
  tasks:
    - name: Do something simple
      ansible.builtin.debug:
        msg: no facts needed here

On a fleet of hundreds of machines that saves a noticeable amount of time. There is also a gather_subset option to collect only some categories. Custom facts, which are files on a host under /etc/ansible/facts.d/ ending in .fact, are exposed under ansible_local, and they let a machine describe itself to Ansible, for instance "I am the primary database".

The facts you will use most are the ones that drive decisions: os_family (for example Debian or RedHat), distribution, distribution_version, hostname, default_ipv4.address, processor_vcpus and memtotal_mb. Facts describe the machine at the moment of gathering, so if a task changes something (installs a package, for instance), the fact is stale until you gather again with the ansible.builtin.setup module.

Try it
  1. Run ansible lab -m ansible.builtin.setup -a "filter=ansible_os_family".
  2. Save and run facts-demo.yml.
  3. Add gather_facts: false to the play and run it again.
the third run fails with 'ansible_facts' ... has no attribute 'distribution' style output (or an undefined-variable error) because nothing gathered the facts. The message is the lesson: a play that disables facts must not use them.

Conditions and loops

Real playbooks make decisions and repeat things. Two keywords do that work: when and loop.

A conditional runs a task only if an expression is true:

YAML
- name: Only on Debian-family systems
  ansible.builtin.debug:
    msg: "this is a Debian-family host"
  when: ansible_facts['os_family'] == "Debian"

- name: Only when a list is non-empty
  ansible.builtin.debug:
    msg: "we have packages"
  when: packages | length > 0

The when value is a Jinja2 expression without {{ }}. Writing braces inside when is an error in current Ansible: when: "{{ a }} == 'b'" fails with Template delimiters are not supported in expressions, and the correct form is when: a == 'b'. The same applies to failed_when, changed_when and until.

Since ansible-core 2.19 there is a second strictness rule that catches people migrating old playbooks: a condition must evaluate to a true boolean. A variable that holds a non-empty string, used directly as when: myvar, now fails with Conditional result (True) was derived from value of type 'str'. Conditionals must have a boolean result. Make the intent explicit instead:

YAML
when: myvar | length > 0     # is it non-empty?
when: enable_feature | bool  # convert "yes"/"true"/"on" strings to a real boolean
when: myvar is defined       # is it set at all?

The bool filter converts recognised truthy strings such as "yes", "true" and "on", and recognised false ones such as "no", "false" and "off". You can combine conditions with and, or and not, or by listing several lines, which are joined with and:

YAML
when:
  - ansible_facts['os_family'] == "RedHat"
  - ansible_facts['distribution_major_version'] | int >= 9

There are also tests, which read naturally with is: result is changed, result is failed, path_stat.stat.exists. A skipped task prints skipping: and counts in the recap.

A loop repeats a task once per item, and the current item is the variable item:

YAML
- name: Create several directories
  ansible.builtin.file:
    path: "{{ playbook_dir }}/out/{{ item }}"
    state: directory
    mode: "0755"
  loop:
    - logs
    - data
    - backups

- name: Create users from a list of dictionaries
  ansible.builtin.debug:
    msg: "would create {{ item.name }} in group {{ item.group }}"
  loop:
    - { name: alice, group: dev }
    - { name: bob, group: ops }
  loop_control:
    label: "{{ item.name }}"

loop_control with label makes the output show just alice instead of the whole dictionary for each pass, which keeps logs readable and, importantly, keeps secrets out of logs when items contain passwords. Older playbooks use with_items; it still works, but loop is the current style. Many modules accept a whole list directly and do it in one go, which is faster than looping:

YAML
- name: Install several packages in one transaction
  ansible.builtin.package:
    name:
      - git
      - curl
    state: present

Conditions and loops combine. A when on a looped task is evaluated once per item, so when: item.enabled decides per pass.

A range cannot be stored directly In ansible-core 2.19 and later, a variable holding range(3) fails with Type 'range' is unsupported for variable storage. Convert it to a list: range(3) | list.
Try it
  1. Add the directory-creating loop to welcome.yml and run it twice.
  2. Add a task that prints a message when: ansible_facts['system'] == "Linux", and run it.
  3. Change the condition to the broken form when: "{{ ansible_facts['system'] }} == 'Linux'" and run --syntax-check then the playbook.
the loop shows one changed line per directory the first time and three ok lines the second. The broken condition fails with the template-delimiters error, which you can now recognise on sight and fix by removing the braces.

Templates: files that adapt

Configuration files usually differ slightly per machine: a hostname here, a port there. Copying a static file cannot express that. A template can. Ansible uses the Jinja2 templating language: you write a file with placeholders, and the template module fills them with variables and writes the result to the target.

Create a folder templates/ and a file in it:

templates/app.conf.j2
# Managed by Ansible. Changes made by hand will be overwritten.
[server]
name = {{ app_name }}
port = {{ app_port }}
host = {{ ansible_facts['hostname'] }}

[features]
{% for feature in features %}
{{ feature }} = on
{% endfor %}
{% if debug_enabled | default(false) %}
debug = true
{% endif %}

Three Jinja2 constructs cover nearly everything. {{ expression }} prints a value. {% for ... %} ... {% endfor %} repeats a block. {% if ... %} ... {% endif %} includes a block conditionally. The .j2 suffix is a convention that tells humans this is a template; Ansible does not require it. Now the task that uses it:

templates-demo.yml
- name: Render configuration
  hosts: lab
  vars:
    app_name: demo
    app_port: 8080
    features:
      - search
      - metrics
  tasks:
    - name: Create the output directory
      ansible.builtin.file:
        path: "{{ playbook_dir }}/out"
        state: directory
        mode: "0755"

    - name: Render app.conf
      ansible.builtin.template:
        src: app.conf.j2
        dest: "{{ playbook_dir }}/out/app.conf"
        mode: "0644"

Ansible finds app.conf.j2 in the templates/ folder beside the playbook automatically. Run it, read out/app.conf, then run it again and confirm changed=0. Change app_port and run --check --diff to watch the diff show exactly the changed line.

template is idempotent: it renders in memory, compares with the file on disk, and only writes if the content differs. That is what makes it safe to run constantly. It also has a superpower for risky files: validate. For a file where a typo would take a service down, have a checker approve the rendered result before it replaces the real one:

YAML
- name: Install the web server config only if it is valid
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    mode: "0644"
    validate: nginx -t -c %s

The %s becomes the path of the temporary rendered file, and if the command exits non-zero the task fails and the live file is left alone. Use this wherever the service offers a config check.

Whitespace in Jinja2 blocks can surprise you: each {% %} line leaves an empty line unless you trim it. Ansible's template module already removes the newline after a block tag by default, which keeps the example above tidy; if output has odd blank lines, look at the block tags first.

Put a header comment in every template A first line such as "Managed by Ansible" saves the next person from editing a file on the server by hand and losing their change on the next run. It is the cheapest documentation you will ever write.
Try it
  1. Create templates/app.conf.j2 and templates-demo.yml as above, and run the playbook.
  2. Add a third item to features and run --check --diff.
  3. Run for real, then run once more.
the diff shows a single added line. The real run reports changed=1 and the next run changed=0. Templates plus the diff flag are how you review configuration changes before they land.

Handlers: act only when something changed

Suppose a task changes a web server's config file. The server must be reloaded to use it, but reloading every run, even when nothing changed, would interrupt traffic for no reason. Handlers solve this. A handler is a task that runs only if another task reported changed and notified it.

handlers-demo.yml
- name: Config change triggers a reload
  hosts: lab
  vars:
    app_name: demo
    app_port: 8080
    features: [search]
  tasks:
    - name: Create the output directory
      ansible.builtin.file:
        path: "{{ playbook_dir }}/out"
        state: directory
        mode: "0755"

    - name: Render app.conf
      ansible.builtin.template:
        src: app.conf.j2
        dest: "{{ playbook_dir }}/out/app.conf"
        mode: "0644"
      notify: Reload the app

  handlers:
    - name: Reload the app
      ansible.builtin.debug:
        msg: "pretend we reloaded the app now"

The notify line names the handler. The handler lives in the handlers: section and has a name that must match. On a real server the handler would be ansible.builtin.service with state: reloaded; here debug stands in so the lab stays safe. Run the playbook and the handler fires, because the template created the file (changed). Run it again and nothing prints: no change, no notification, no reload. Change a value and the handler fires again.

Three rules govern handlers, and each has caught everyone at least once.

  1. Handlers run once, at the end of the play's task section, no matter how many tasks notified them. If five tasks change five config files and all notify "Restart nginx", nginx restarts one time.
  2. They run in the order they are defined in the handlers: section, not the order they were notified.
  3. If a task fails, the play stops on that host before the handlers run, and a change that already happened will not trigger its handler on the next run (because the next run sees no change). If a failed run leaves the service out of step with its config, use --force-handlers or set force_handlers: true on the play.

If a handler must run earlier, say a service restart has to happen before a later task uses the service, you can force it with ansible.builtin.meta: flush_handlers:

YAML
- name: Run any pending handlers now
  ansible.builtin.meta: flush_handlers
A handler name must match exactly notify: Restart nginx with a handler named Restart Nginx does not match, because names are case-sensitive, and the run fails with a message that the requested handler was not found. Copy and paste the name, and keep handler names short and imperative.
Try it
  1. Save handlers-demo.yml, delete out/app.conf and run the playbook.
  2. Run it again.
  3. Change app_port in the play and run it a third time.
the handler message appears on runs one and three and is absent on run two. That is the pattern: a reload happens precisely when, and only when, the file it depends on changed.

Handling failure: register, failed_when, blocks

By default, when a task fails on a host, Ansible stops running tasks on that host and carries on with the others. That default is right most of the time, but real automation needs finer control, and Ansible gives you a few keywords for it.

register captures a task's result so later tasks can inspect it. failed_when and changed_when redefine what "failed" and "changed" mean for that task, which matters for commands whose exit codes are not a simple success or failure.

YAML
- name: Check whether the app answers
  ansible.builtin.command: /usr/local/bin/app-check
  register: check
  changed_when: false
  failed_when: check.rc not in [0, 2]

- name: Report the result
  ansible.builtin.debug:
    msg: "check returned {{ check.rc }}: {{ check.stdout }}"

Here exit codes 0 and 2 both count as acceptable, and anything else fails the task. check.rc is the exit code, and check.stdout is the printed output; every registered result of a command-like module has these and more, which -v shows.

ignore_errors: true on a task lets the play continue even if it fails. Use it sparingly: it hides problems, and a failed task that is ignored still looks like a failure in the output, which confuses readers. Prefer failed_when to define precisely what is acceptable. If a host cannot be reached, that is a separate situation (unreachable), handled with ignore_unreachable: true, and since ansible-core 2.19 a timeout while becoming another user counts as unreachable rather than failed.

When several tasks belong together and you want try-and-recover behaviour, group them in a block:

YAML
- name: Upgrade with a safety net
  block:
    - name: Run the migration
      ansible.builtin.command: /opt/app/migrate
  rescue:
    - name: Report the failure and roll back
      ansible.builtin.debug:
        msg: "migration failed on {{ inventory_hostname }}, rolling back"
  always:
    - name: Remove the maintenance flag either way
      ansible.builtin.file:
        path: /tmp/maintenance
        state: absent

The block runs; if any of its tasks fails, the rescue tasks run; the always tasks run regardless. That is exactly the try, except and finally of a programming language. A block also lets you apply a keyword, such as become: true or a when, to a group of tasks once.

One more guard deserves a place in every serious playbook: stating your assumptions so they fail early and clearly.

YAML
- name: Stop early if this is not a supported system
  ansible.builtin.assert:
    that:
      - ansible_facts['system'] == "Linux"
      - app_port | int > 1024
    fail_msg: "This playbook needs Linux and a non-privileged port"
    success_msg: "Preconditions hold"

assert is to a playbook what an input check is to a function. It turns a confusing failure halfway through into a clear one at the start.

Try it
  1. Write a play with a task that runs ansible.builtin.command: /bin/false, registered as r, with failed_when: false and changed_when: false.
  2. Follow it with a debug that prints r.rc.
  3. Now remove the failed_when line and run again.
with failed_when: false the play passes and prints 1 (the exit code of /bin/false). Without it the task fails with a non-zero return code and the host is marked failed. You decided what "failure" means, instead of accepting the module's default.

Connecting to real servers: SSH, users, and sudo

Everything so far ran on your own machine. The jump to real remote servers needs three things to be true: Ansible can log in over SSH, it knows which user to log in as, and, when a task needs root, it can become root.

Step one: SSH access. Test it by hand first, because if ssh user@host does not work, Ansible will not either.

BASH
ssh-keygen -t ed25519 -C "ansible control node"
ssh-copy-id deploy@10.0.0.11
ssh deploy@10.0.0.11 'python3 --version'

A key-based login with no password prompt is what you want, since automation cannot type passwords. The last command also confirms Python exists on the target.

Step two: describe the machines and tell Ansible who to be, in the inventory or ansible.cfg:

inventory.ini
[web]
web01 ansible_host=10.0.0.11
web02 ansible_host=10.0.0.12

[web:vars]
ansible_user=deploy

If the key is not in the default location, name it with ansible_ssh_private_key_file=~/.ssh/ansible_ed25519 in the inventory or --private-key on the command line. Check the connection for the whole group with ansible web -m ansible.builtin.ping.

Step three: privilege escalation, called become. Installing packages and editing files in /etc require root. You ask for it per play or per task with become: true. By default Ansible uses sudo to become root.

web.yml
- name: Install and start nginx
  hosts: web
  become: true
  tasks:
    - name: Install nginx
      ansible.builtin.package:
        name: nginx
        state: present

    - name: Start nginx now and at boot
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

If the deploy user needs a sudo password, add --ask-become-pass (or -K) and Ansible prompts once. A dedicated automation account with passwordless sudo for the commands it needs is the usual solution for unattended runs, configured once by your operations or security team. Never put the sudo password into a plain file in the repository; the vault section below shows the right place.

Now the first connection problems you will meet, and what they mean.

Message Cause Fix
Permission denied (publickey,password). Wrong user, or the key is not authorised on the host Check ansible_user, run ssh-copy-id, use --private-key
Host key verification failed. The host is not in your known_hosts yet ssh to it once, or ssh-keyscan host >> ~/.ssh/known_hosts
Connection timed out Wrong address, firewall, or the host is down Verify ansible_host, try plain ssh
sudo: a password is required become needs a password Add -K, or set up passwordless sudo for the automation user

The second row tempts everybody to set host_key_checking = False. Do that only in a throwaway lab. Host key checking is what protects you from connecting to an impostor, and it is on by default for good reason. When you are stuck, add -vvv to see the exact ssh command Ansible ran; you can copy it and run it yourself, which takes most of the mystery out of connection problems.

No more sshpass for passwords If you must use password login while learning, -k (--ask-pass) asks for the SSH password. Since ansible-core 2.19, the ssh connection uses an askpass mechanism by default (password_mechanism = ssh_askpass), so the old sshpass program is no longer required. Older guides that insist on installing it predate that change. Keys remain the right answer for anything you keep.
The paramiko transport is gone Very old guides suggest connection: paramiko or smart as a workaround. The paramiko connection plugin was removed in ansible-core 2.21, and smart was removed earlier. Use the default ssh connection, which uses the OpenSSH program on your control node.

If you have a real machine available, swap your lab inventory for one like the above and re-run welcome.yml: the hosts: line and the tasks do not change at all. Only the inventory changed, which is the point of separating them.

Try it
  1. If you have any SSH-reachable machine, add it to a new group in your inventory with ansible_host and ansible_user.
  2. Run ansible thatgroup -m ansible.builtin.ping, then ansible thatgroup -b -m ansible.builtin.command -a "whoami".
  3. If you do not have one, run ansible lab -m ansible.builtin.command -a "whoami" locally and think about how the output would differ with -b.
without -b the output is your login user; with -b it is root. That single difference is what become does, and it is why a task that "works in the shell" can fail in Ansible when the shell user was root and Ansible's was not.

The everyday commands, grouped by what you are trying to do

You now know enough pieces to see the command line as a toolbox. Here it is, grouped by intention rather than alphabetically.

Checking before you run. These commands change nothing and should precede any real run.

BASH
ansible-playbook site.yml --syntax-check       # is the YAML valid?
ansible-playbook site.yml --list-hosts         # which hosts will be touched?
ansible-playbook site.yml --list-tasks         # which tasks will run, in which order?
ansible-playbook site.yml --list-tags          # which tags exist?
ansible-playbook site.yml --check --diff       # dry run with file diffs

Narrowing a run. You rarely want to run everything everywhere.

BASH
ansible-playbook site.yml --limit web01        # only this host (or pattern): -l
ansible-playbook site.yml --tags config        # only tasks tagged "config": -t
ansible-playbook site.yml --skip-tags slow     # everything except the "slow" tag
ansible-playbook site.yml --start-at-task "Render app.conf"
ansible-playbook site.yml --step               # confirm each task interactively

Tags are labels you attach to tasks, so you can run a subset:

YAML
- name: Render app.conf
  ansible.builtin.template:
    src: app.conf.j2
    dest: /etc/app/app.conf
    mode: "0644"
  tags: [config]

Supplying values and credentials.

BASH
ansible-playbook site.yml -e "app_port=9090"               # one extra variable
ansible-playbook site.yml -e @extra-vars.yml               # a file of variables
ansible-playbook site.yml -u deploy --private-key ~/.ssh/key
ansible-playbook site.yml -K                               # ask for the sudo password
ansible-playbook site.yml --ask-vault-password             # ask for the vault password

Seeing more or less.

BASH
ansible-playbook site.yml -v        # task results
ansible-playbook site.yml -vvv      # connection details
ansible-playbook site.yml -f 20     # 20 hosts in parallel (forks, default 5)

Learning and inspecting.

BASH
ansible-doc ansible.builtin.copy             # manual for a module
ansible-doc -l                               # list every module
ansible-doc -t keyword when                  # what a keyword means
ansible-inventory --graph                    # groups and hosts
ansible-config dump --only-changed           # settings that differ from defaults
ansible-galaxy collection list               # installed collections

Two details about the options. Tag selection has special tags: always (runs even when you select other tags, unless skipped), never (runs only when asked for explicitly), tagged and untagged. And when you limit a run with --limit, the hosts: pattern in the play still applies on top, so --limit can only narrow the selection, never widen it.

A safe routine before any real run Syntax check, then --list-hosts, then --check --diff, then the real run. Four commands and a minute of reading turn "I hope this is right" into "I have seen what this will do".
Try it
  1. Add tags: [config] to the template task in handlers-demo.yml and tags: [dirs] to the directory task.
  2. Run ansible-playbook handlers-demo.yml --list-tags.
  3. Run it with --tags config, then again with --skip-tags config.
the tag listing shows both tags; the first run executes only the template (and its handler if the file changed); the second executes only the directory task. Tags let you re-run the slice you just edited in seconds.

Secrets: a first look at Ansible Vault

Sooner or later a playbook needs a password, an API token or a private key. You cannot leave it in plain text in a repository that other people can read, and you cannot leave it out. Ansible Vault encrypts files, or single values, with a password (AES256) so the encrypted text can be committed safely and decrypted when a playbook runs.

Create an encrypted file, which opens your editor and saves the contents encrypted:

BASH
ansible-vault create group_vars/lab/vault.yml

Put a variable inside, prefixed clearly so it is obvious where it comes from:

group_vars/lab/vault.yml
vault_db_password: "correct-horse-battery-staple"

On disk the file now starts with a header such as $ANSIBLE_VAULT;1.1;AES256 followed by hexadecimal ciphertext. You can still use the variable in a playbook as usual, and supply the password when you run:

BASH
ansible-playbook site.yml --ask-vault-password
ansible-playbook site.yml --vault-password-file ~/.vault_pass

The other vault commands are what you would guess: ansible-vault view, edit, encrypt (an existing file), decrypt and rekey (change the password). To encrypt just one value inside an otherwise readable file:

BASH
ansible-vault encrypt_string 'supersecret' --name db_password

It prints a block beginning db_password: !vault | that you paste into a normal vars file. A popular pattern keeps two files per group: a plain vars.yml that refers to {{ vault_db_password }}, so anyone reading the repository sees which variables exist, and a vault.yml that holds the encrypted values.

Keep these rules from day one:

  1. Never commit the vault password. If you keep it in a file such as .vault_pass for convenience, add that file to .gitignore immediately. You can point Ansible at it with vault_password_file = .vault_pass in ansible.cfg.
  2. Use no_log: true on any task that handles a secret, so the value does not appear in the terminal or logs: for example a user task that sets a password, or a uri task sending a token.
  3. Remember the error messages. Forgetting the password shows Attempting to decrypt but no vault secrets found. A wrong password shows Decryption failed (no vault secrets were found that could decrypt). Both mean the password did not reach Ansible, not that the file is damaged.

Vault protects secrets at rest, in the repository. It is not a full secrets platform: anyone with the password can read everything, and there is no audit trail or rotation. Larger teams move to an external secret manager, which the Mid-level and Senior guides cover.

A decrypted value is only as safe as your output Running with -vvv or printing a variable with debug shows the plain value in your terminal and any saved logs. Mark sensitive tasks no_log: true, and never debug a secret, even briefly.
Try it
  1. Run ansible-vault create group_vars/lab/vault.yml, choose a password, and add vault_demo: hello.
  2. Run cat group_vars/lab/vault.yml and look at the header.
  3. Run a playbook that prints {{ vault_demo }} with --ask-vault-password, then once without it.
the cat shows the $ANSIBLE_VAULT header and ciphertext only. With the password the playbook prints hello; without it the variable cannot be decrypted and the run stops with the "no vault secrets found" error.

Roles and collections: where this grows

A single playbook file works for a small job. A real project reaches a point where the same tasks, such as "install and configure nginx", are needed in several playbooks. Copying them around recreates the drift problem. Roles and collections are Ansible's answer, and a beginner should know what they are, even if you build none yet.

A role is a folder with a fixed layout that packages a related set of tasks, handlers, templates, default variables and files:

TEXT
roles/
  nginx/
    tasks/main.yml        # the tasks
    handlers/main.yml     # the handlers
    templates/            # Jinja2 templates
    files/                # static files
    defaults/main.yml     # default variable values (the weakest, meant to be overridden)
    vars/main.yml         # role-internal variables (stronger)
    meta/main.yml         # metadata and dependencies

Ansible looks inside those folders by name, so a template task in tasks/main.yml finds its template in templates/ without a path. You apply a role to hosts like this:

site.yml
- name: Configure web servers
  hosts: web
  become: true
  roles:
    - nginx

ansible-galaxy role init nginx creates the skeleton folder for you. When a role needs a value to differ between hosts, it reads a variable that has a default in defaults/main.yml, and the caller overrides it from group_vars or the play. That is the design: behaviour is fixed, configuration is passed in.

A collection is the larger distribution format: a bundle of modules, plugins and roles under a namespace.collection name. You already use one, ansible.builtin. The community package includes many more; with only ansible-core you install collections yourself from Ansible Galaxy, the public hub at galaxy.ansible.com.

BASH
ansible-galaxy collection install community.general
ansible-galaxy collection list

To keep a project reproducible, list what it needs in a requirements.yml file and install from it, so a teammate or a CI job gets the same content:

requirements.yml
collections:
  - name: community.general
    version: ">=11.0.0,<12.0.0"
BASH
ansible-galaxy collection install -r requirements.yml

Roles downloaded from Galaxy are other people's code that will run as root on your machines. Read them before you use them, pin a version, and prefer maintained ones with clear documentation. Since ansible-core 2.21, ansible-galaxy collection install skips collections that declare themselves incompatible with your core version, and if you see couldn't resolve module/action 'community.general.something', the collection is usually missing or the name is misspelled.

A sensible ladder for a learner: one playbook first, then group_vars and templates, then extract repeated tasks into a role, then pull in collections when you need a module that ships outside ansible.builtin. Do not start with roles; they are a refactoring you earn once there is something to refactor.

Try it
  1. Run ansible-galaxy role init --init-path roles demo_role from your lab folder.
  2. Look inside the folder it created and find the file that would hold the tasks and the one that would hold default variables.
  3. Add a task to roles/demo_role/tasks/main.yml that uses debug, and apply the role from a tiny playbook.
a directory tree matching the layout above. Running the playbook prints your debug message under a task name prefixed with the role name, which is how you tell role tasks apart in the output.

Configuration: ansible.cfg and the settings worth knowing

You have used ansible.cfg already. Here is how Ansible decides which configuration to read, and which settings a beginner should know.

Ansible reads one configuration file, searching in this order and stopping at the first it finds. Files are not merged:

  1. The file named by the ANSIBLE_CONFIG environment variable.
  2. ansible.cfg in the current directory (ignored if that directory is world-writable, a safety measure).
  3. ~/.ansible.cfg in your home directory.
  4. /etc/ansible/ansible.cfg.

Because the current directory wins over the home directory, a project-level ansible.cfg beside your playbooks is the right place for project settings, and it travels with the repository. You can always check which one is active and what differs from the defaults:

BASH
ansible --version | head -3                  # shows "config file = ..."
ansible-config dump --only-changed           # only what you have customised
ansible-config init --disabled > ansible.cfg # a fully commented template to read

Every setting can also be given as an environment variable, which is handy for one-off overrides and for CI. The pattern is the key name in capitals with an ANSIBLE_ prefix, although a few have different names, so check the docs rather than guessing.

What ansible.cfg key Environment variable Default
Inventory location [defaults] inventory ANSIBLE_INVENTORY /etc/ansible/hosts
Parallel hosts [defaults] forks ANSIBLE_FORKS 5
SSH user [defaults] remote_user ANSIBLE_REMOTE_USER your current user
Private key [defaults] private_key_file ANSIBLE_PRIVATE_KEY_FILE none
Host key checking [defaults] host_key_checking ANSIBLE_HOST_KEY_CHECKING true
Python interpreter discovery [defaults] interpreter_python ANSIBLE_PYTHON_INTERPRETER auto
Connection timeout [defaults] timeout ANSIBLE_TIMEOUT 10 seconds
Retry files [defaults] retry_files_enabled ANSIBLE_RETRY_FILES_ENABLED false
Result format [defaults] callback_result_format ANSIBLE_CALLBACK_RESULT_FORMAT json
Log to a file [defaults] log_path ANSIBLE_LOG_PATH none
Sudo by default [privilege_escalation] become_method ANSIBLE_BECOME_METHOD sudo

A sensible starting ansible.cfg for a project:

ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
callback_result_format = yaml
forks = 10

A few settings have surprises. interpreter_python is overridden per host by the variable ansible_python_interpreter, which is how you point a machine at a specific Python, such as /usr/bin/python3.12. The key collections_path is singular, not plural, in both the file and the environment variable. Retry files, the .retry leftovers that old Ansible versions wrote, are off by default now, despite what older pages say.

The precedence for settings, from weakest to strongest, is the built-in default, the config file, environment variables, and command-line options. An environment variable left exported in your shell can therefore override ansible.cfg without any sign in the file. When behaviour puzzles you, ansible-config dump --only-changed is the first place to look.

Try it
  1. Run ansible-config dump --only-changed in your lab folder.
  2. Run ANSIBLE_FORKS=2 ansible-config dump --only-changed and compare.
  3. Run it from your home directory instead.
in the lab folder you see your three settings with their source (the file path). The environment variable adds a line showing ANSIBLE_FORKS as the source for forks. From your home directory your project's settings are not listed, because the project file is only read when you are in that folder.

The errors you will actually meet

Every beginner meets the same ten errors. Since ansible-core 2.19 the format is [ERROR]: Task failed: ... followed by Origin: with the file, line and column, an excerpt of your source with a marker, and sometimes a <<< caused by >>> chain. Read the top line for the symptom, the origin for where, and the bottom of the chain for why.

1. The YAML did not parse: missing quotes around a template.

TEXT
[ERROR]: YAML parsing failed: This may be an issue with missing quotes around a template block.

The hint prints raw: {{ some_var }} and Should be: raw: "{{ some_var }}". A value that starts with {{ must be quoted. Fix: add double quotes.

2. A variable is undefined.

TEXT
[ERROR]: Task failed: Finalization of task args for 'ansible.builtin.debug' failed: Error while resolving value for 'msg': 'not_defined_var' is undefined

Older versions said The task includes an option with an undefined variable, and you will see that wording in old posts. The meaning is the same: a typo, a variable defined in a scope the task cannot see, or facts disabled. Fix the name, define it, or use | default(...) if it is optional.

3. A condition is not a boolean.

TEXT
A 'when' expression failed: Conditional result (True) was derived from value of type 'str' ... Conditionals must have a boolean result.

You wrote when: some_string. Write a comparison: when: some_string | length > 0, or when: some_value | bool.

4. Template delimiters inside a condition.

TEXT
A 'when' expression failed: Syntax error in expression. Template delimiters are not supported in expressions

You wrote when: "{{ x }} == 'y'". Remove the braces: when: x == 'y'.

5. The module cannot be found.

TEXT
[ERROR]: couldn't resolve module/action 'community.general.not_a_module'. This often indicates a misspelling, missing collection, or incorrect module path.

Check the spelling and the FQCN, then run ansible-galaxy collection list. If the collection is absent, install it with ansible-galaxy collection install. A module that used to exist may also have moved to a different collection.

6. No hosts matched.

TEXT
[WARNING]: provided hosts list is empty, only localhost is available. Note that the implicit localhost does not match 'all'
[WARNING]: Could not match supplied host pattern, ignoring: web

The inventory was not loaded or the group name is wrong. Check -i, the inventory = line, and ansible-inventory --graph.

7. The host is unreachable.

TEXT
fatal: [web01]: UNREACHABLE! => {"changed": false, "msg": "Task failed: Failed to connect to the host via ssh: ... Permission denied (publickey,password).", "unreachable": true}

This is about the connection, not your tasks. Test with plain ssh, check ansible_user, the key and the address, and add -vvv.

8. Sudo wants a password.

TEXT
fatal: [web01]: FAILED! => {"changed": false, "msg": "Task failed: Premature end of stream waiting for become success.\n>>> Standard Error\nsudo: a password is required"}

Use -K to be prompted, or configure passwordless sudo for the automation user.

9. Python is missing on the target.

TEXT
[ERROR]: Task failed: Action failed: The module interpreter '/usr/bin/python9' was not found.

On a bare host you may instead see MODULE FAILURE ... /usr/bin/python3: not found. Install Python using the raw module, which needs none: ansible host -m ansible.builtin.raw -a "dnf -y install python3". Or point ansible_python_interpreter at the correct path.

10. The vault password did not arrive.

TEXT
Attempt to use undecryptable variable: Attempting to decrypt but no vault secrets found.

Add --ask-vault-password or --vault-password-file. A wrong password gives Decryption failed (no vault secrets were found that could decrypt).

Two more environmental ones come up in CI and minimal containers. ERROR: Ansible requires blocking IO on stdin/stdout/stderr means something wrapping the process set its pipes non-blocking; piping the output through cat or running under a real terminal fixes it. ERROR: Ansible could not initialize the preferred locale: unsupported locale setting means the environment lacks a UTF-8 locale; export LC_ALL=C.UTF-8.

A last habit that makes all ten easier: read the first error, not the last. Ansible stops a host at its first failure, so everything after it is either skipped or fallout. Fix the first, re-run, and expect the list to shrink.

Do not disable protection to silence an error The search results for these errors suggest host_key_checking = False, ignore_errors: true and ALLOW_BROKEN_CONDITIONALS=True. Each one hides a real problem. The third is explicitly a temporary bridge for migrating old playbooks, not a fix. Understand the error, then fix the cause.
Try it
  1. Create a playbook with a single debug task whose msg is {{ nope }}, unquoted, and run --syntax-check.
  2. Fix the quoting and run it.
  3. Fix the undefined variable with "{{ nope | default('ok') }}" and run it once more.
two different errors in sequence: first a YAML parsing error that names the missing quotes, then an undefined-variable error, then a clean run. They look alike at a glance and have completely different causes, and now you can tell them apart.

Putting it all together

Time to combine everything into one small, real project that you could hand to a colleague. It builds a tiny "service configuration" folder on your own machine, using variables, group variables, a vaulted secret, a template, a handler, tags, an assertion, and a block, then proves it is idempotent.

The layout:

TEXT
ansible-lab/
  ansible.cfg
  inventory.ini
  site.yml
  group_vars/
    lab/
      vars.yml
      vault.yml
  templates/
    service.conf.j2
  .gitignore
  .vault_pass

Start with the settings, pointing at the vault password file:

ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
callback_result_format = yaml
vault_password_file = .vault_pass
inventory.ini
[lab]
localhost ansible_connection=local

Create the password file and keep it out of git, then create the encrypted secret file. Because ansible.cfg names the password file, there is no prompt:

BASH
echo "a-long-random-lab-password" > .vault_pass
chmod 600 .vault_pass
printf '.vault_pass\nout/\n' > .gitignore
ansible-vault create group_vars/lab/vault.yml

In the editor, enter one line and save:

group_vars/lab/vault.yml
vault_api_token: "lab-token-12345"

The plain variables file refers to the secret by name, so anyone reading it knows what exists:

group_vars/lab/vars.yml
service_name: demo-api
service_port: 8080
service_workers: 2
features:
  - search
  - metrics
api_token: "{{ vault_api_token }}"

The template:

templates/service.conf.j2
# Managed by Ansible. Do not edit by hand.
[service]
name = {{ service_name }}
port = {{ service_port }}
workers = {{ service_workers }}
host = {{ ansible_facts['hostname'] }}
token = {{ api_token }}

[features]
{% for feature in features %}
{{ feature }} = on
{% endfor %}

And the playbook, which ties it together:

site.yml
- name: Configure the demo service
  hosts: lab
  tasks:
    - name: Check our assumptions
      ansible.builtin.assert:
        that:
          - service_port | int > 1024
          - service_workers | int >= 1
          - features | length > 0
        fail_msg: "service_port, service_workers and features need sensible values"
      tags: [always]

    - name: Prepare the service directories
      ansible.builtin.file:
        path: "{{ playbook_dir }}/out/{{ item }}"
        state: directory
        mode: "0755"
      loop:
        - conf
        - logs
      tags: [dirs]

    - name: Render and install the configuration
      block:
        - name: Write service.conf
          ansible.builtin.template:
            src: service.conf.j2
            dest: "{{ playbook_dir }}/out/conf/service.conf"
            mode: "0600"
          notify: Reload the service
          no_log: true

        - name: Verify the file landed
          ansible.builtin.stat:
            path: "{{ playbook_dir }}/out/conf/service.conf"
          register: conf_stat
          failed_when: not conf_stat.stat.exists
      rescue:
        - name: Explain the failure
          ansible.builtin.debug:
            msg: "Could not install the configuration on {{ inventory_hostname }}"
      tags: [config]

  handlers:
    - name: Reload the service
      ansible.builtin.debug:
        msg: "service would be reloaded now"

Read it the way a reviewer would. The assert runs first and carries the always tag, so it checks the inputs even when you run only one tag. The directory task uses a loop. The template task is wrapped in a block with a rescue; it has no_log: true because the rendered content contains the token; it sets the mode to 0600 so only the owner reads the secret; and it notifies the handler. The stat task verifies the file exists and fails the block if not.

Run the sequence you learned, in order:

BASH
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml --list-tasks
ansible-playbook site.yml --check --diff
ansible-playbook site.yml
ansible-playbook site.yml

The first run creates two directories, writes the file, and runs the handler, so the recap shows changed above zero. The second run shows changed=0 and no handler message. Now edit service_workers to 4, run --check --diff (the diff is hidden because no_log: true covers that task, which is the price of hiding a secret), run for real, and watch the handler fire once. Finally run only a slice:

BASH
ansible-playbook site.yml --tags config
ansible-playbook site.yml --skip-tags dirs

If you have a remote machine, the only change needed is the inventory (an address and a user) and adding become: true where the tasks touch system locations. The playbook and templates stay the same.

Try it
  1. Build the project exactly as shown and run the five commands in order.
  2. Change service_port to 80 and run it.
  3. Put it back, then change the vaulted token with ansible-vault edit group_vars/lab/vault.yml and run it again.
the port change stops at the assertion with your fail_msg before anything is written, which is the early, readable failure you wanted. After the token edit, the run reports one change and fires the handler, while the terminal never prints the token because the task has no_log: true.

What you can now do, and what comes next

You started with no Ansible and you can now do the whole loop that makes it useful. You can install the tool and verify it. You can describe machines in an inventory and select them with patterns. You can run one-off ad hoc commands and, more importantly, write playbooks that declare a desired state. You can read the output, distinguish ok from changed from failed, and use --check --diff to preview changes. You can use variables, facts, conditions, loops and templates, act on change through handlers, handle failures with failed_when and blocks, keep secrets in Vault, connect to real servers over SSH with become, and read the errors that stop most beginners. That is enough to automate a real small server, and the habits (name every task, use FQCNs, check before you run, prefer modules to commands) are the ones that keep larger projects healthy.

What comes next depends on what you want to do.

  1. Go deeper on Ansible itself. The Mid-level guide covers roles in full, collections and requirements.yml, dynamic inventory from cloud providers, variable precedence in detail, testing with Molecule, linting with ansible-lint, and debugging beyond -vvv. The Senior guide covers running Ansible as a platform, scaling, security and upgrades.
  2. Connect it to neighbouring tools. Ansible configures machines, and something has to create them. Terraform provisions the servers and networks, and Ansible then configures them. If your workloads run in containers, start with Docker, and for clusters continue with Kubernetes. Ansible can drive all of them, but it is a different tool with a different job, and knowing where each one stops is half of using them well.
  3. Practise on something real. Take a server you maintain by hand today and write the playbook that rebuilds its setup from nothing. Run it in --check mode against the live server first; every difference the diff reports is a place where your documentation and your reality disagreed. Fixing those is the most valuable automation work there is.

A final point about the region. Teams in the Gulf and Egypt often manage servers in a mix of cloud regions and local data centres, sometimes under data-residency requirements. Ansible is well suited to that, because it needs only SSH and runs from wherever your control node sits, so you can keep the control node, the inventory and the secrets inside the jurisdiction your employer requires. Check that your vault passwords, SSH keys and any fact caches live where your policy says they may.

Sources