HostiServer
2026-11-09 11:20
Ansible: Architecture, Installation, Inventory, Ad-Hoc
📚 "Ansible" series, part 1 of 10:
- Ansible: Architecture, Installation, Inventory, and Your First Ad-Hoc Commands ← you are here
- Ansible Playbooks: Tasks, Modules, Handlers, Loops, and Tags
- Ansible Variables and Jinja2 Templates: host_vars, group_vars, Precedence
- Ansible in Practice: Nginx, Firewall, SSH Keys, and Users on a Real VPS
- Ansible Roles: Structure, Includes/Imports, and Best Practices
- Ansible Galaxy and Collections: requirements.yml and Installation
- Ansible Vault: Secrets, vault-id, and Playbook Integration
- Ansible at Scale: Forks, Serial, Pipelining, and SSH Multiplexing for 100+ Nodes
- Dynamic Inventory in Ansible: Plugins for Hetzner, AWS, and GCP
- Ansible + CI/CD: ansible-lint and Automatic Deployment via Forgejo and GitHub Actions
Ansible: Architecture, Installation, Inventory, and Your First Ad-Hoc Commands
In the Docker on a VPS series, we installed Docker, set up Compose, brought up Nginx with SSL, and even built a multi-node Docker Swarm cluster. We did every one of those steps by hand: SSH in, type the commands, check the result. That's fine for one server. For three it's already noticeably tedious. For thirty it becomes a job of its own: thirty terminals, the same commands thirty times over, and a guarantee that something will go wrong on one of the servers, and you'll find out only later.
Ansible solves exactly this problem. You describe the desired state of your servers once (which packages are installed, which services are running, which files are in place), and Ansible brings any number of machines to that state itself, repeating it identically every time if needed. This is the first article in a ten-part series: we'll cover how Ansible is built, how to install it, how to describe your servers, and how to run your first commands with no playbook at all. From there the series moves on to simple playbooks, roles, secrets, large infrastructures, and automatic deployment with CI/CD.
1.1 The problem: dozens or hundreds of servers with no manual work
Administering several servers by hand has three chronic problems. First, speed: every change multiplies by the number of machines. Second, drift: servers configured at different times by different people gradually become different from one another, and nobody remembers why anymore. Third, reproducibility: when you need one more server just like the others, the instructions exist only in someone's head or in a stale text file.
Configuration automation replaces manual steps with a description you can store in git, review, inspect, and repeat.
1.2 Scripts, Ansible, or Chef/Puppet/Salt
There are three typical approaches to this problem, and they differ in their working model, not just their syntax.
| Approach | How it works | Agent on servers | Where it wins, where it loses |
|---|---|---|---|
| Bash scripts | A sequence of commands: "do this, then this" | Not needed | Quick to start. But rerunning often breaks state, and you have to write error handling and cross-distro differences yourself |
| Ansible | A description of the desired state in YAML; modules check for themselves whether a change is needed | Not needed: works over SSH | Low barrier to entry, nothing to install on the servers. Very large fleets need performance tuning (part 8 of the series) |
| Chef / Puppet / Salt | A central server, with an agent on the nodes that regularly reconciles state on its own | Usually required (Salt also has a salt-ssh mode) | Continuous self-checking of state and scale. But its own language or DSL, a separate management infrastructure, and agents that have to be maintained themselves |
For most teams with dozens of servers, whether VPS or dedicated machines, Ansible gives the best balance between upfront effort and the payoff from automation.
1.3 Four key properties
- Agentless. Nothing needs to be installed on managed servers: no agent, no daemon. SSH and Python are enough.
- Plain SSH as the transport. Ansible uses what's already on every Linux server. For Windows machines as managed nodes, WinRM (or SSH) is used instead.
- Idempotence. Running the same task again doesn't break the state. The module checks the current state, and if it already matches the desired one, it changes nothing and reports "ok" instead of "changed."
- YAML as the language. The automation description reads without special knowledge, so a colleague who didn't write it can still read and review it.
Idempotence is worth walking through with an example, since it's exactly what sets Ansible apart from scripts. The command echo "line" >> /etc/file in a script appends a line every time it runs, so after five runs the file has five identical lines. An Ansible module that describes "this line should be in this file" checks whether the line is already there and adds it only once. The same goes for packages: state=present means "should be installed," not "install it again."
⚠️ Idempotence isn't unconditional. It's a property of modules, not of Ansible itself. The command and shell modules run an arbitrary command and have no way to know whether it changed anything, so they always report "changed." Ansible has creates and removes parameters for exactly this case. We'll go into choosing modules in more depth in part 2.
ℹ️ Where we're coming from: if you haven't gone through the containers series yet, it starts with the article "Docker on a VPS: Installation and Your First Container." It isn't required for Ansible, but that example makes it easy to see just how many identical manual steps pile up once you have more than one server.
2. Architecture: How It Works
2.1 Three components
- Control node. The machine you run Ansible from: a laptop, a CI server, or a bastion host. Ansible is installed here, along with the inventory, playbooks, and SSH keys. The control node runs on Linux or macOS; Windows is supported only through WSL.
- Managed nodes. The target servers you're managing. All they need is Python and SSH access to an ordinary POSIX shell.
- Inventory. The list and description of managed nodes: their names and addresses, how to connect to them, and how they're grouped.
2.2 What happens during execution
Every task goes through the same cycle:
- The control node connects to the server over SSH.
- It copies a small Python module with the needed logic onto the server (into a temporary directory).
- It runs the module and collects the result as JSON.
- It cleans up the temporary files.
- It returns the result to the control node, where Ansible shows you the status: ok, changed, or failed.
An interesting consequence follows from this: since the module is delivered fresh to the server every single time, version management causes no problems at all. There's nothing on the servers themselves that ever needs updating.
2.3 What managed servers don't need
No agent, no daemon running at all times, no open port besides SSH. That both simplifies maintenance (nothing to update) and shrinks the attack surface (no extra listener on a network port).
2.4 The other side of the coin: the control node needs protecting
The control node holds the keys to your entire fleet of servers, which makes it a critical node. Practical rules: use a dedicated SSH key just for automation, protect it with a passphrase or keep it in ssh-agent, restrict who can log into the control node, and never store its private key on shared machines. We'll cover secrets management separately in part 7 of the series (Ansible Vault).
2.5 Python requirements on managed servers
Ansible's modules run on the server, so Python is required there. Most server images for Debian, Ubuntu, AlmaLinux, or Rocky have it out of the box. Minimal images (containers, some cloud images) may not, in which case you'll need to install it before the first run. The exceptions are network modules (which run on the control-node side, with no Python needed on the device) and the raw module, which runs a command with no Python at all and is exactly why it's commonly used for installing Python in the first place.
3. Installing Ansible on the Control Node
3.1 Two packages: ansible and ansible-core
Before installing, it's worth knowing there are two packages. ansible-core is minimal: the language itself, the execution engine, and the built-in modules. ansible is the fuller "batteries included" package: the same thing plus a set of collections curated by the community. Collections get their own part of the series later (Ansible Galaxy and Collections, part 6); for now either package will do, though the full one is more convenient since it already includes most of the modules you'll need.
3.2 Installation methods
| System | Method | Notes |
|---|---|---|
| Any Linux, macOS (for a CI server and VPS: the primary choice) | pipx install --include-deps ansible |
Isolated environment, easy upgrades, doesn't touch the system Python |
| Ubuntu | Official PPA ppa:ansible/ansible |
Carries newer versions than the standard repositories |
| Debian | The ansible package from the standard repository, or from the Ubuntu PPA |
The standard repository's version may be outdated |
| AlmaLinux / Rocky / CentOS Stream | dnf after enabling EPEL |
Check which version EPEL provides before using it in a project |
| Fedora | dnf install ansible or ansible-core |
Both packages are available |
| macOS | brew install ansible |
Or via pipx, as on Linux |
| Windows | Only in WSL | Not supported natively as a control node |
3.3 Recommendation: pipx
For a CI server and a VPS, we recommend pipx. It's a practical choice: Ansible installs into its own virtual environment, doesn't conflict with the system Python, and upgrades with a single command. The official documentation names pip as the primary method and pipx as a common alternative for cases where pip isn't available due to OS policy. And that's exactly the situation on modern Debian and Ubuntu: the system Python is marked "externally managed," and pip install without extra flags refuses to run there. So pipx is the natural choice here.
# Ubuntu / Debian
sudo apt update
sudo apt install -y pipx
pipx ensurepath
# AlmaLinux / Rocky (after enabling EPEL)
sudo dnf install -y pipx
pipx ensurepath
# The actual install
pipx install --include-deps ansible
After pipx ensurepath, open a new terminal so PATH picks up the change. To upgrade later:
pipx upgrade --include-injected ansible
3.4 Installing from the Ubuntu PPA
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible
For Debian, the newer version comes from the same PPA, but through manually adding the repository with a key under /usr/share/keyrings/; the exact steps, matched to Debian and Ubuntu codenames, are on the official per-OS installation page.
3.5 Checking the install
ansible --version
python3 --version
The ansible --version command shows the ansible-core version, the path to the config file, the path to the modules, and the Python version it's running under. A separate command, ansible-community --version, shows the version of the full ansible package.
3.6 Versions and Python requirements
As of November 2026, the current branch of the full package is ansible 14.x, running on ansible-core 2.21. This version needs Python 3.12-3.14 on the control node, and Python 3.9-3.14 on managed servers. Two practical things follow from that:
- An old Python on the control node. On some common distributions the system Python is older than 3.12 (Ubuntu 22.04 or Debian 12, for example). In that case it's worth installing a separate, newer Python and pointing pipx at it (
pipx install --python python3.12 --include-deps ansible), or using an older ansible-core branch. - Very old managed servers. If the target system's Python is older than 3.9, the current ansible-core version can't work with it, and such machines need an older ansible-core version.
Version requirements change several times a year, so check the support table on the Ansible documentation's releases page before installing.
⚠️ Don't mix installation methods. If Ansible is installed through both apt and pipx, you'll end up with two copies, and the ansible command will run whichever one comes first in PATH. This is a classic source of confusing errors ("the docs say this option exists, but I don't have it"). Pick one method and check ansible --version after any change.
4. SSH Keys: Passwordless Access
4.1 Why passwords don't work at scale
Ansible performs actions on many servers at once, and prompting for a password on every connection isn't feasible. On top of that, SSH passwords are also worse than keys from a security standpoint: they can be guessed, they end up in command history, and they're hard to rotate centrally. So the standard approach is SSH keys.
4.2 Generating a key pair
If you don't already have a dedicated key for automation, create one on the control node:
ssh-keygen -t ed25519 -C "ansible-control" -f ~/.ssh/ansible_ed25519
ed25519 is a modern, short, and fast key type. The tool will offer to set a passphrase. For a key that controls your whole fleet of servers, a passphrase is a good idea; to avoid typing it every time, add the key to ssh-agent (ssh-add ~/.ssh/ansible_ed25519). On a CI server, where the key is used with no human involved, it's usually kept in the platform's own secrets store, which we'll cover in part 10.
4.3 Distributing the public key
ssh-copy-id -i ~/.ssh/ansible_ed25519.pub admin@192.0.2.11
This command needs to run for every managed server. For now, any existing account with sudo rights will do (admin in the example). Later, in part 4, we'll have Ansible itself create a dedicated automation user; for now, the priority is just getting access set up.
4.4 Checking by hand before the first run
ssh -i ~/.ssh/ansible_ed25519 admin@192.0.2.11 "hostname && sudo -n true && echo sudo-ok"
This command answers three questions at once: whether key-based login without a password works, whether the hostname is correct, and whether the user can run sudo without being prompted for a password. The -n flag makes sudo fail instead of prompting. If everything works by hand, Ansible will work too. If it doesn't, the problem needs solving now, not partway through a task run.
4.5 Privileges: sudo with or without a password
Most administrative tasks need root privileges, which Ansible gets through sudo (the become parameter). There are two approaches:
- Prompt for the sudo password every time: add
--ask-become-pass(-K) to your commands. Safer, but inconvenient for automation. - Allow passwordless sudo for the automation user: convenient for CI and automatic deployment, but weakens protection: whoever gets the key gets root too.
echo 'admin ALL=(ALL) NOPASSWD:ALL' | sudo tee /etc/sudoers.d/ansible
sudo chmod 440 /etc/sudoers.d/ansible
sudo visudo -cf /etc/sudoers.d/ansible
That last command checks the file's syntax: a mistake in sudoers can lock you out of sudo entirely, so it's always worth checking. Keep a root session open until you've confirmed the new rule actually works.
⚠️ Don't use direct root login for Ansible. An ordinary account with sudo leaves a trail in the logs (showing exactly who ran what) and lets you disable root login over SSH entirely. That lockdown is one of the tasks we'll automate in part 4.
4.6 Connection parameters
If usernames, ports, or keys differ between servers, that's described through host parameters: ansible_user (who connects), ansible_port (a non-standard SSH port), ansible_ssh_private_key_file (which key to use). These are set in the inventory or in the configuration, covered next.
4.7 First connection and known_hosts
On the first connection, SSH asks whether you trust the server's key fingerprint. In automation, that question halts execution. The right fix is to add the fingerprints ahead of time:
ssh-keyscan -H 192.0.2.11 192.0.2.12 198.51.100.21 >> ~/.ssh/known_hosts
Note that ssh-keyscan trusts whatever answers from the server at the moment of the request. For sensitive systems, verify the fingerprints (ssh-keygen -lf) against what the provider's panel or the server's console shows. Disabling host key checking instead isn't a good idea, we'll get into why in the section on ansible.cfg.
5. Inventory: Describing the Infrastructure
The inventory is a "map" of your fleet: which servers exist, what they're called, how to connect to them, and how they're grouped. Ansible doesn't guess where to connect: it only works with the nodes described in the inventory.
5.1 Two formats: INI and YAML
Ansible supports both formats out of the box. INI is simpler and shorter; YAML scales better and is more convenient when hosts carry a lot of variables. The same fleet in INI:
[webservers]
web1 ansible_host=192.0.2.11
web2 ansible_host=192.0.2.12
[databases]
db1 ansible_host=198.51.100.21 ansible_port=2222
[production:children]
webservers
databases
[all:vars]
ansible_user=admin
ansible_python_interpreter=/usr/bin/python3
And in YAML:
all:
vars:
ansible_user: admin
ansible_python_interpreter: /usr/bin/python3
children:
webservers:
hosts:
web1:
ansible_host: 192.0.2.11
web2:
ansible_host: 192.0.2.12
databases:
hosts:
db1:
ansible_host: 198.51.100.21
ansible_port: 2222
production:
children:
webservers:
databases:
The addresses in these examples come from ranges reserved for documentation (192.0.2.0/24 and 198.51.100.0/24); substitute your own.
5.2 Node groups
Groups are the inventory's main tool: they let you address many servers by a single name. Two groups always exist with no explicit description: all (every host) and ungrouped (hosts that belong to no other group). Every host belongs to at least one of them.
5.3 Nested groups
In INI, nesting is declared with a :children suffix on the group header (as in the [production:children] example); in YAML, with the children key. A host in a child group automatically belongs to the parent too: web1 belongs to both webservers and production. This is handy for describing separate environments, say, staging and production groups, each with its own webservers. Circular relationships between groups aren't allowed.
5.4 Inventory-level variables
Parameters that determine how to connect:
ansible_host: the real address or DNS name, if it differs from the host's name in the inventory (in the example,web1is just a convenient label);ansible_port: the SSH port, if it isn't 22;ansible_user: the user to connect as;ansible_python_interpreter: the path to Python on the server. Setting this explicitly removes the warning about the auto-detected interpreter and prevents errors on systems with several Python versions.
Variables declared in the [all:vars] section apply to all hosts; group variables (for example, [webservers:vars]) apply only to that group's members; variables on a host's own line take the highest priority among these three. We'll cover the full variable precedence system in part 3.
5.5 Host ranges
For servers of the same kind, you don't need to list each one: web[01:50].example.com creates fifty hosts, from web01 to web50. A step is set with a third number (web[01:50:2] gives only the odd ones), and letter ranges are supported too (db-[a:f].example.com).
5.6 Where to keep the inventory
- A single file: enough for a small fleet. Simple, everything visible at once.
- A directory (for example,
inventory/with several files): Ansible reads every file in it in alphabetical order. Convenient as the fleet grows, or when part of the inventory is generated automatically (we'll get to that in part 9, on dynamic inventory). If one variable is defined in several files, the one that comes last alphabetically wins, so you can control file order with prefixes (01-,02-).
Either way, keep the inventory in git alongside the rest of your automation code, but without passwords or keys in plain text.
5.7 Checking the inventory
ansible-inventory -i inventory/hosts.ini --list
ansible-inventory -i inventory/hosts.ini --graph
The first command prints out everything Ansible "understands" about your hosts, in JSON, variables included. The second shows a tree of the groups. For the example above, it looks roughly like this:
@all:
|--@production:
| |--@databases:
| | |--db1
| |--@webservers:
| | |--web1
| | |--web2
|--@ungrouped:
Get in the habit of running this check after every change to the inventory: it immediately surfaces mistakes in groups and variables before they turn into unexplained behavior on the servers.
6. ansible.cfg: Basic Configuration
6.1 Where Ansible looks for the config
Ansible checks four locations, in this order, and uses the first file it finds. The other files are ignored; their contents don't get merged in:
- The file named by the
ANSIBLE_CONFIGenvironment variable, if it's set; ansible.cfgin the current directory (the project config);~/.ansible.cfgin the home directory;/etc/ansible/ansible.cfg(system-wide).
For projects, the second option is almost always best: ansible.cfg sits at the root of the repository alongside the inventory and playbooks, so everyone working with the repository gets the same settings. The config file line in ansible --version's output shows exactly which configuration Ansible is currently using.
⚠️ A config in a world-writable directory is ignored. Ansible won't load ansible.cfg from the current directory if it's world-writable: otherwise another user on the system could plant their own file and control your runs. If your config "isn't being picked up," check the directory's permissions (ls -ld .).
6.2 Key parameters
[defaults]
inventory = ./inventory/hosts.ini
remote_user = admin
private_key_file = ~/.ssh/ansible_ed25519
host_key_checking = True
inventory: the default inventory path, so you don't have to pass-ion every command;remote_user: the user to connect as, if it isn't set in the inventory;private_key_file: the default key;host_key_checking: whether to verify host key fingerprints (enabled by default).
You can check which parameters you've changed from their defaults with ansible-config dump --only-changed.
6.3 Why host_key_checking = False is only for tests
Plenty of guides suggest disabling host key checking to get rid of the trust prompt on first connection. It works, but it also completely removes protection against server impersonation (a man-in-the-middle attack): Ansible will happily connect to whoever answers at the expected address, and will hand them everything you send to the server, including files and variables. For experiments on throwaway virtual machines, that's acceptable. For production, the right path is section 4.7: add the fingerprints to known_hosts ahead of time and leave checking enabled.
7. Ad-Hoc Commands: Ansible Without a Playbook
An ad-hoc command is a single action run on one or several servers straight from the command line, with no playbook written. For one-off operations, it's the fastest way to go.
7.1 Syntax
ansible <pattern> -m <module> -a "<arguments>"
Three parts: the pattern (who to target), the module (what to do), and the module arguments. If no module is given, ansible.builtin.command is used.
7.2 Pattern: which hosts to act on
| Pattern | What it means |
|---|---|
all |
Every host in the inventory |
webservers |
Every host in the group |
web1 |
One specific host |
web* |
Wildcard: everything whose name starts with "web" |
webservers:databases |
Union of groups (either one or the other) |
production:&webservers |
Intersection: hosts that belong to both groups |
webservers:!web2 |
Exclusion: everything in the group except web2 |
For exclusion in bash, the ! character usually needs single quotes: ansible 'webservers:!web2' -m ping, otherwise the shell tries to expand it as a history reference. Any pattern can additionally be narrowed with the --limit (-l) option, for example -l web1.
7.3 Useful modules for everyday tasks
| Module | Purpose | Example arguments |
|---|---|---|
ping |
Check that Ansible can connect and run Python on the server | no arguments |
command |
Run a command with no shell (no pipes or redirects) | -a "uptime" |
shell |
Run a command through a shell (|, >, variables allowed) |
-a "df -h | head -5" |
copy |
Copy a file from the control node to the server | -a "src=./motd dest=/etc/motd mode=0644" |
fetch |
Pull a file from the server to the control node | -a "src=/etc/os-release dest=./fetched/" |
service |
Manage services | -a "name=nginx state=restarted" |
apt / dnf |
Manage packages on Debian/Ubuntu or RHEL-like systems | -a "name=nginx state=present" |
The ping module has nothing to do with ICMP ping: it really does log into the server over SSH, run a small piece of Python code, and return "pong." So a successful response means SSH access, Python, and permissions are all working, which is a lot more than just "the server responds on the network."
7.4 Examples
# Check that every server is reachable
ansible all -m ping
# Uptime on every web server (command by default)
ansible webservers -a "uptime"
# Free disk space, the pipe needs shell
ansible all -m ansible.builtin.shell -a "df -h / | tail -1"
# Restart Nginx with root privileges
ansible webservers -b -m ansible.builtin.service -a "name=nginx state=restarted"
# System facts (filtered by name)
ansible web1 -m ansible.builtin.setup -a "filter=ansible_distribution*"
The -b (--become) option enables privilege escalation through sudo. You'll also often need -u (connect as a different user), -i (specify the inventory, if it's not set in the configuration), and -K (prompt for the sudo password).
7.5 Dry-run mode: --check
The --check (-C) flag runs a "dry run": Ansible shows what it would do, without changing anything on the servers. It works well with modules that can predict their result (apt, copy, service, and others). But command and shell are usually skipped in check mode, since Ansible has no way to know what an arbitrary command would do. So a dry run isn't a universal safety guarantee; it's best used together with a check on a single server.
7.6 Parallelism: forks
By default, Ansible works with five servers at once (the forks parameter). For a fleet of a hundred machines, that means twenty sequential "waves." You can raise parallelism for a single run with the -f option:
ansible all -f 20 -m ping
More isn't always better: every fork consumes memory and a connection on the control node, and too aggressive a value can run into SSH or network limits. We'll cover fine-grained performance tuning (forks, pipelining, SSH multiplexing) in part 8.
8. A First Real Scenario
Let's bring everything together into one practical scenario: check the fleet, look at its state, and safely update packages on the web servers.
8.1 Step 1: is everything reachable
ansible all -m ping
A successful response looks like this:
web1 | SUCCESS => {
"changed": false,
"ping": "pong"
}
Until ping succeeds on every node, there's no point going further.
8.2 Step 2: checking uptime on every node
ansible all -a "uptime"
web1 | CHANGED | rc=0 >>
12:41:03 up 12 days, 3:07, 1 user, load average: 0.08, 0.03, 0.01
Notice the word CHANGED, even though uptime didn't change anything. That's the same property of the command module from section 1: it has no way of knowing whether the command changed the state, so it always reports "changed." It's not a bug, just a reminder that truly idempotent actions need purpose-built modules, not command.
8.3 Step 3: updating packages on the web servers
First a dry run on one server, then an actual run on that same one, and only then on all of them:
# Debian / Ubuntu: dry run on one host
ansible webservers -l web1 -b -m ansible.builtin.apt -a "update_cache=yes upgrade=dist" --check
# Actual run on one host
ansible webservers -l web1 -b -m ansible.builtin.apt -a "update_cache=yes upgrade=dist"
# After checking: on every web server
ansible webservers -b -m ansible.builtin.apt -a "update_cache=yes upgrade=dist"
# For RHEL-like systems
ansible webservers -b -m ansible.builtin.dnf -a "name='*' state=latest"
That sequence (the "canary" approach: one first, then everyone) is a habit that saves you from a situation where a bad update takes down the whole fleet at once. For production servers, in part 8 we'll cover the serial directive, which automates gradual updates in groups.
8.4 Reading the output
| Status | What it means | What to do |
|---|---|---|
SUCCESS (ok) |
The task ran, the state already matched the desired one, nothing changed | Nothing |
CHANGED |
The task ran and changed something (or the module can't tell) | Confirm the change is expected |
UNREACHABLE |
Couldn't connect to the server | Check the network, port, key, user, and the fingerprint in known_hosts; try the same ssh manually |
FAILED |
Connected, but the task failed | Read the msg field: common causes are a missing -b for tasks that need root, Python not installed, or a mistake in the module's arguments |
The most common confusion for beginners: UNREACHABLE means an access problem (SSH), while FAILED means an execution problem (module, permissions, Python). These are different classes of failure, and diagnosis starts in different places for each.
9. The Limits of Ad-Hoc and What Comes Next
9.1 Where ad-hoc runs out
Ad-hoc commands are ideal for one-off operations: checking reachability, looking up a version, restarting a service, gathering facts. But they're not suited to repeatable scenarios: a sequence of actions isn't preserved, logic ("if something changed, restart the service") can't be expressed, and the result can't be reviewed or tracked in git. The moment you catch yourself typing the same long command a third time, it's time for a playbook.
9.2 What we did in this article
- Understood how Ansible is built: control node, managed nodes, inventory, agentless execution over SSH.
- Installed it on the control node and sorted out the versions and Python requirements.
- Set up passwordless key-based access and prepared the servers for automation.
- Described the server fleet in the inventory, with groups and variables.
- Configured
ansible.cfgwithout disabling host key checking. - Ran our first ad-hoc commands and learned to read their output.
9.3 What's next in the series
The next article covers playbooks: files that describe a sequence of tasks, letting you use handlers (restarting a service only after a configuration change), loops, and tags. That's where real idempotent automation actually starts to appear. From there, the series moves from variables and Jinja2 templates to configuring a real VPS in practice (part 4: nginx, firewall, SSH keys, users), then roles, Ansible Galaxy, Vault for secrets, scaling to hundreds of nodes, dynamic inventory, and finally automatic deployment through Forgejo or GitHub Actions. For anyone who wants a head start on CI/CD, that topic's starting point is covered in "What Is CI/CD: From Manual Deploys to Automated Pipelines."
📚 Series navigation:
You're reading part 1 of 10, "Ansible: Architecture, Installation, Inventory, and Your First Ad-Hoc Commands."
Next: Part 2. Ansible Playbooks: Tasks, Modules, Handlers, Loops, and Tags →
🚀 Servers for Your Ansible Fleet
Getting started with Ansible needs just two things: a small server for the control node, and one or two machines for it to manage. Hostiserver gives you resources you can safely experiment on, and which later grow into a full infrastructure.
🖥️ Dedicated Servers
- From $90/mo, full control over the hardware for heavy workloads
- No resource neighbors: predictable performance for databases and build nodes
- 24/7 support: we'll help with networking and SSH access between your servers
💻 Cloud (VPS) Hosting
- From $19.95/mo, KVM isolation, dedicated vCPU and RAM
- Full root access: Ansible gets the permissions it needs, no workarounds
- Matching plans across several VPS: convenient for building a test fleet of identical nodes
- Easy to scale: add one more server to the inventory, and it's under management
💬 Not sure which option you need?
💬 Write to us and we'll help you decide!
Frequently Asked Questions
- How is Ansible better than plain bash scripts?
A script describes a sequence of actions, while Ansible describes a desired state. An Ansible module checks first whether a change is actually needed, so rerunning it doesn't break the system or duplicate the result (idempotence). On top of that, the same description works across different distributions: the modules account for the differences you'd otherwise have to handle by hand in a script. Scripts remain a fine choice for very simple one-off tasks.
- Does anything need to be installed on managed servers?
No agent, no daemon. You just need SSH access and Python (for most modules). Standard server images for Debian, Ubuntu, AlmaLinux, or Rocky already have Python. If it's missing, the initial installation can be done with the
rawmodule, which runs with no Python needed:ansible all -m raw -a "apt install -y python3" -b. Network modules run on the control-node side and need no Python on the device itself.
- Can Ansible be run from Windows?
Windows isn't supported natively as a control node. The option is WSL (Windows Subsystem for Linux), where Ansible runs just like on ordinary Linux. Managing Windows servers as managed nodes is possible, but that uses WinRM (or SSH) instead, and it's a separate topic we won't be covering in this series.
- Ansible returns UNREACHABLE. Where should I start debugging?
Try the same connection by hand:
ssh -i key user@address. If it doesn't work by hand, the problem is in the network, port, key, or user, and Ansible won't fix anything here. If it works by hand but not in Ansible, checkansible_user,ansible_port, andansible_hostin the inventory (theansible-inventory --listcommand shows what Ansible actually "sees"), and whether the host's fingerprint is inknown_hosts. For detailed diagnostics, add the-vvvflag to the command.
- Pipx, apt, or pip: what's the best way to install Ansible?
For a CI server and a VPS, we recommend pipx: an isolated environment, easy upgrades, no conflicts with the system Python (on modern Debian and Ubuntu,
pip installwithout extra flags is blocked there). A package from a PPA or the OS repository is also fine, especially if you want it to upgrade together with the rest of the system's packages. The main rule: pick one method and don't mix them, or you'll end up with two copies of Ansible.
- Is it safe to give a user passwordless sudo for Ansible?
It's a trade-off. Passwordless sudo is needed for automatic runs with no human involved (CI/CD), but whoever gets that user's SSH key gets root too. The risk is reduced by: a dedicated key used only for automation, protecting that key with a passphrase or a secrets store, restricting access to the control node, and allowing SSH only from the addresses that need it. For manual runs, a password works fine: the
--ask-become-pass(-K) option.
- Is a dedicated server needed for the control node?
No, to start, your laptop or any Linux machine with access to the servers will do. A dedicated server (a bastion or a CI node) makes sense once several people work with Ansible, or once runs happen automatically. At that point it becomes the single place where keys and the inventory live, which makes it easier to protect and control. A small VPS is usually enough for this.