By the end you'll know
- Why logging in to each server, or running a script on each, stops scaling
- What Ansible is: agentless, works over SSH, and describes results instead of steps
- What idempotent means, and why it's the most important idea in this track
- How to start three practice servers with Vagrant and point Ansible at them
- How to reach every server at once, and what happens when one is down
Before you start
- You need: a Mac or Linux computer with Vagrant (opens in a new tab) and a virtual machine provider: VMware Fusion (as here) or VirtualBox. The Linux track's Lesson 7 sets these up.
- Where commands run: your own computer's terminal. Ansible runs here and reaches out to the servers; nothing needs installing on them.
- Memory: the three servers use 1 GB each while running.
vagrant haltstops them when you're done. - Helpful first: the Linux track, especially Lesson 15, where a bash script built the bookshop server. This track replaces that script.
The problem: one server is easy
The Linux track's last lesson built a whole server with setup.sh: packages, users, files, permissions, services. It works, and it was a big step up from typing everything by hand. Now imagine there are thirty of those servers. Or three, each slightly different. Or one, but the script needs to change every week. The cracks show quickly:
- Running it everywhere. You copy the script to each server and run it, or write a loop of
sshcommands. One server is down; did the rest finish? Which ones? - Running it again. A script runs top to bottom. Run it twice, and anything that isn't carefully guarded happens twice.
setup.shhandles that, but look at how: itsputfunction, which copies one file only if it's different, is 30 lines of careful bash, with comments explaining the traps. - Knowing what it did. Did it change anything on this run, or was everything already right? A script prints what it prints. There's no built-in "nothing to do".
Think of a recipe versus a photo of the finished dish. A script is a recipe: do this, then this, then this. Hand it to someone whose kitchen is half-done already, and they'll add the eggs twice. A photo says only what the result should look like. Someone with a half-made dish looks at it, sees what's missing, and does only that. Ansible works from the photo.
What Ansible is
Ansible is a tool for configuring many machines the same way, from one place. Three ideas define it.
It describes results, not steps. You don't write "run apt install nginx". You write "nginx should be present". Ansible checks each server, and only acts where the server doesn't match. Each piece of work is done by a module, a small program for one job: apt for packages, copy for files, service for services, and thousands more.
It's agentless. Nothing is installed on the servers except what's already there: SSH, and Python, which almost every Linux has. Ansible runs on your computer, the control node, connects to each server over SSH, sends the module across, runs it, and reads back the result. Other tools, like Puppet and Chef, need their own program running on every server first.
It's idempotent. Run it once, and the servers end up as described. Run it again, and nothing changes, because nothing needs to. Run it a hundred times: same result. That's what makes it safe to run whenever you like, and it's the idea every lesson in this track comes back to.
Three practice servers
The practice files are in a repository. On your own computer, in the folder where you keep projects:
git clone https://github.com/Kwamib/ansible-practice.git
cd ansible-practice
Install Ansible. On a Mac, with Homebrew:
brew install ansible
ansible --version | head -3
ansible [core 2.18.6]
config file = /Users/you/Apps/ansible-practice/ansible.cfg
configured module search path = ['/Users/you/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible-core is the engine; the Homebrew package adds a large collection of extra modules on top. Notice the second line: Ansible found an ansible.cfg in this folder, and will use it. More on that in a moment.
The Vagrantfile describes three small Ubuntu servers: two web servers, web1 and web2, and a database server, db1. Start them:
time vagrant up
==> web1: Machine booted and ready!
==> web1: Setting hostname...
==> web2: Machine booted and ready!
==> web2: Setting hostname...
==> db1: Machine booted and ready!
==> db1: Setting hostname...
real 2m28.852s
(Shortened: Vagrant prints much more.) Two and a half minutes for three servers. Check they're up:
vagrant status
Current machine states:
web1 running (vmware_desktop)
web2 running (vmware_desktop)
db1 running (vmware_desktop)
Telling Ansible where they are
Ansible connects with SSH, so SSH needs to know how to reach each server. Vagrant knows, and vagrant ssh-config writes it out:
vagrant ssh-config > ssh_config
grep -E "^Host |HostName|User |IdentityFile" ssh_config
Host web1
HostName 127.0.0.1
User vagrant
IdentityFile /Users/you/Apps/ansible-practice/.vagrant/machines/web1/vmware_desktop/private_key
Host web2
HostName 127.0.0.1
User vagrant
IdentityFile /Users/you/Apps/ansible-practice/.vagrant/machines/web2/vmware_desktop/private_key
Host db1
HostName 127.0.0.1
User vagrant
IdentityFile /Users/you/Apps/ansible-practice/.vagrant/machines/db1/vmware_desktop/private_key
Each server, its address (all three are 127.0.0.1, your own computer, each on its own forwarded port, in lines left out here), the user to log in as, and its SSH key, from Lesson 11 of the Linux track. With that file, plain SSH already works, server by server:
for h in web1 web2 db1; do ssh -F ssh_config $h "hostname; uptime -p"; done
web1
up 2 minutes
web2
up 1 minute
db1
up 0 minutes
That's the old way: a loop, one server after another, and you read the output yourself. Now Ansible. Two small files in the repo tell it what it needs. ansible.cfg:
[defaults]
inventory = inventory.ini
# Find Python on each server by looking, without warning about it.
interpreter_python = auto_silent
[ssh_connection]
# ssh_config is written by: vagrant ssh-config > ssh_config
# It tells SSH each VM's address, user and key.
ssh_args = -F ssh_config
And inventory.ini:
# Which servers Ansible manages, in groups.
[web]
web1
web2
[db]
db1
ansible.cfgis Ansible's settings file. Here: where the inventory is, how to find Python on the servers (more on that below), and to give SSH thessh_configfile, so Ansible connects exactly the way the loop above did.inventory.iniis the inventory: the list of servers Ansible manages, in groups in square brackets.web1andweb2are inweb;db1is indb. Lesson 2 is all about inventories.
Ask Ansible which servers it knows:
ansible all --list-hosts
ansible web --list-hosts
hosts (3):
web1
web2
db1
hosts (2):
web1
web2
all is a group Ansible makes for you: every server. Any group name works in its place.
Reaching every server at once
The ping module checks that Ansible can connect to a server, and that Python works there. It has nothing to do with the network ping from the Networking track:
ansible all -m ping
[WARNING]: Platform linux on host db1 is using the discovered Python
interpreter at /usr/bin/python3.12, but future installation of another Python
interpreter could change the meaning of that path. See
https://docs.ansible.com/ansible-
core/2.18/reference_appendices/interpreter_discovery.html for more information.
...
db1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
All three answered pong. Things to notice:
-m pingpicks the module. A one-off command like this, with no playbook, is an ad-hoc command.- The order isn't fixed:
db1answered first. Ansible works on several servers at the same time, five by default, so whichever finishes first prints first. That's why thirty servers don't take thirty times as long. "changed": false: every result says whether anything was changed. Hold on to that.- The warning (one per server; the others are left out above) is Ansible saying it found Python by looking, at
/usr/bin/python3.12, rather than being told. Harmless here. This lesson's first run didn't yet have theinterpreter_python = auto_silentline inansible.cfg, which keeps the same behavior without the warning; the repo has it now, so you won't see this. From here on, the warnings are left out of this lesson's output.
-m command -a "..." runs any command, with -a for its arguments:
ansible all -m command -a "uptime -p"
ansible db -m command -a "free -h"
web1 | CHANGED | rc=0 >>
up 2 minutes
web2 | CHANGED | rc=0 >>
up 1 minute
db1 | CHANGED | rc=0 >>
up 0 minutes
db1 | CHANGED | rc=0 >>
total used free shared buff/cache available
Mem: 953Mi 243Mi 561Mi 1.1Mi 227Mi 710Mi
Swap: 3.8Gi 0B 3.8Gi
The same as the ssh loop, labeled by server, with each command's exit code (rc=0 means it succeeded). And CHANGED, even though uptime changes nothing. The command module can't know what a command does, so it always assumes it changed something. That's a hint about where this lesson is going.
Ansible also gathers facts: what each server is, from its hardware to its operating system. The setup module shows them:
ansible all -m setup -a "filter=ansible_distribution*"
web2 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "24",
"ansible_distribution_release": "noble",
"ansible_distribution_version": "24.04",
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false
}
(One server shown; the other two are the same.) Ubuntu 24.04, "noble". Lesson 4 uses facts to make decisions, like "only on Ubuntu".
Describe the result, not the steps
Here's the whole idea, in four commands. Make sure the tree package is present on the web servers:
ansible web -m apt -a "name=tree state=present update_cache=true" --become
web2 | CHANGED => {
"cache_update_time": 1761257545,
"cache_updated": false,
"changed": true,
"stdout_lines": [
"Reading package lists...",
"Building dependency tree...",
"Reading state information...",
"The following NEW packages will be installed:",
" tree",
"0 upgraded, 1 newly installed, 0 to remove and 238 not upgraded.",
...
"Setting up tree (2.1.1-2ubuntu3.24.04.2) ...",
...
]
}
web1 | CHANGED => {
"changed": true,
...
}
(Shortened: the full result includes everything apt printed.) --become runs the module as root, through sudo, which installing packages needs; it's Ansible's word for sudo. Both servers: CHANGED, "changed": true, tree was installed. apt also mentions 238 packages that could be upgraded: Lesson 9 deals with those.
Now run the same thing again:
ansible web -m apt -a "name=tree state=present" --become
web2 | SUCCESS => {
"cache_update_time": 1761257545,
"cache_updated": false,
"changed": false
}
web1 | SUCCESS => {
"cache_update_time": 1761257545,
"cache_updated": false,
"changed": false
}
SUCCESS, "changed": false. The apt module checked, found tree already installed, and did nothing. That's idempotence, and it's why you can run Ansible as often as you like: it only acts where the server isn't already as described. It's what setup.sh's 30-line put function did by hand, for one kind of thing, built into every module.
The opposite works the same way. Make sure tree is absent, twice:
ansible web -m apt -a "name=tree state=absent" --become
ansible web -m apt -a "name=tree state=absent" --become
web1 | CHANGED => {
"changed": true,
...
}
web2 | CHANGED => {
"changed": true,
...
}
web1 | SUCCESS => {
"changed": false,
...
}
web2 | SUCCESS => {
"changed": false,
...
}
Removed, then already removed. You never say "uninstall": you say what should be true.
Not every module is idempotent
command runs whatever you tell it, every time:
ansible web -m command -a "touch /tmp/marker"
web1 | CHANGED | rc=0 >>
web2 | CHANGED | rc=0 >>
The file module manages files by state. But watch state=touch:
ansible web -m file -a "path=/tmp/marker state=touch"
web2 | CHANGED => {
"changed": true,
...
}
web1 | CHANGED => {
"changed": true,
...
}
CHANGED, even though the file already existed. touch means "update the file's time", like the Linux touch command, and the time is different every run, so it always reports a change. A good lesson early on: a module is only idempotent if what you ask for can already be true. "The file exists" can be; "the file was touched just now" can't. Compare state=absent, which can be:
ansible web -m file -a "path=/tmp/marker state=absent"
ansible web -m file -a "path=/tmp/marker state=absent"
web1 | CHANGED => {
"changed": true,
...
}
web2 | CHANGED => {
"changed": true,
...
}
web1 | SUCCESS => {
"changed": false,
...
}
web2 | SUCCESS => {
"changed": false,
...
}
Removed, then already gone. Prefer modules like apt, file and copy, that describe a state, over command and shell. When you do need command, Lesson 3 shows how to make it behave.
When a server isn't there
Ask for a server that isn't in the inventory:
ansible web3 -m ping
[WARNING]: Could not match supplied host pattern, ignoring: web3
[WARNING]: No hosts matched, nothing to do
Only warnings, not an error: a typo in a server name quietly does nothing, so read the output. Now stop db1, and ping everything, with -o for one line per server:
vagrant halt db1
ansible all -m ping -o
web1 | SUCCESS => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"ping": "pong"}
web2 | SUCCESS => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"ping": "pong"}
db1 | UNREACHABLE!: Failed to connect to the host via ssh:
UNREACHABLE! for db1, and the other two carried on regardless. One server being down doesn't stop the rest, and the result says exactly which one failed: no guessing which servers the loop got to. Bring it back, and write the SSH settings again in case its address changed:
vagrant up db1
vagrant ssh-config > ssh_config
ansible all -m ping -o
db1 | SUCCESS => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"ping": "pong"}
web1 | SUCCESS => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"ping": "pong"}
web2 | SUCCESS => {"ansible_facts": {"discovered_interpreter_python": "/usr/bin/python3.12"},"changed": false,"ping": "pong"}
All three again. (The -o lines also carried the interpreter warning inside them; it's left out here.)
In real systems
- Ansible is one of a few tools for this, usually called configuration management: making many servers match a description, and keeping them that way. Puppet, Chef and Salt do the same job with an agent on every server. Ansible's lack of an agent is why it's so often the first one people pick up: if you can SSH in, you can use it.
- It sits next to Terraform, not instead of it. Terraform creates infrastructure: servers, networks, databases. Ansible configures what's on the servers once they exist. A common pattern is Terraform to build the machines, then Ansible to set them up.
- Kubernetes changed where it's used. In a cluster like CivicGrid's, applications ship as container images, and the cluster keeps them running, so there's little to configure on each node by hand. Ansible is still everywhere for the servers outside a cluster, for setting up the nodes themselves, and for jobs like patching a fleet: Lesson 9.
Spot the problem
Work out what's wrong before you read the explanation.
The patch run that "worked". Someone runs ansible all -m command -a "apt-get upgrade -y" --become across 40 servers. Every line comes back CHANGED, and they move on. A week later, three servers turn out never to have been upgraded. What went wrong, and what would have shown it? Two things. First, CHANGED from command means nothing: it says that on every run, whatever happened. Only a module that checks the state, like apt with upgrade=dist, reports a real change. Second, they didn't look for the servers that didn't say CHANGED. Three were UNREACHABLE, listed among the 40 lines and easy to miss, and Ansible carried on without them, as it's designed to. Ansible's summary at the end of a playbook run, which Lesson 3 introduces, counts unreachable and failed servers so they're hard to miss.
Keep this
| To | Run |
|---|---|
| Install Ansible (Mac) | brew install ansible |
| Start the practice servers | vagrant up, in ansible-practice |
| Write their SSH settings | vagrant ssh-config > ssh_config |
| List the servers in a group | ansible web --list-hosts |
| Check Ansible can reach them | ansible all -m ping |
| One line per server | ansible all -m ping -o |
| Run a command everywhere | ansible all -m command -a "uptime -p" |
| See a server's facts | ansible db1 -m setup -a "filter=ansible_distribution*" |
| Make sure a package is installed | ansible web -m apt -a "name=tree state=present" --become |
| Make sure a file is gone | ansible web -m file -a "path=/tmp/marker state=absent" |
| Stop, or start, the servers | vagrant halt, vagrant up |
Where you are now
You have three servers, Ansible on your computer, and you can reach all of them at once, change them, and see which changed. Lesson 2 looks closer at the inventory: groups of groups, settings for each server, picking exactly which servers a command touches, and finding the right module for a job.
Check yourself
What does it mean that Ansible is agentless?
The same apt command, run twice, says CHANGED then SUCCESS. Why?
Why does command -a 'uptime -p' report CHANGED?
file with state=touch says CHANGED every run. Why isn't it idempotent?
db1 is stopped. What does ansible all -m ping do?
What's in inventory.ini?
Key terms
- Configuration management: making many servers match a description, and keeping them that way.
- Control node: the computer Ansible runs on. Here, yours.
- Agentless: needing nothing on the servers beyond SSH and Python.
- Inventory: the list of servers Ansible manages, in groups.
- Module: a small program for one job, like
apt,fileorping, that Ansible sends to a server and runs. - Ad-hoc command: one module, run once, from the command line:
ansible all -m ping. - Idempotent: running it again changes nothing, because nothing needs to change.
- become: running as another user, usually root, through
sudo. - Facts: what Ansible discovers about each server, like its operating system.
Go deeper
- The Ansible documentation: Getting started (opens in a new tab), and Introduction to ad hoc commands (opens in a new tab).
- Try:
ansible all -m setup | lesson one server, and find its memory, its IP address and its kernel version among the facts. - Try: install
treeondb1only, then check withansible all -m command -a "which tree". Which servers fail, and why does that count as a failure?