Ansible basics · Lesson 1 of 11 · about 25 minutes

What Ansible solves

Why configuring servers by hand, or with a bash script, stops working as the number of servers grows, and what Ansible does instead: describe the result you want, and let it work out what to change, over plain SSH. Then three practice servers on your own computer, and a first look at Ansible reaching all of them at once.

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 halt stops 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 ssh commands. 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.sh handles that, but look at how: its put function, 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:

BASH
git clone https://github.com/Kwamib/ansible-practice.git
cd ansible-practice

Install Ansible. On a Mac, with Homebrew:

BASH
brew install ansible
ansible --version | head -3
OUTPUT
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:

BASH
time vagrant up
OUTPUT
==> 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:

BASH
vagrant status
OUTPUT
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:

BASH
vagrant ssh-config > ssh_config
grep -E "^Host |HostName|User |IdentityFile" ssh_config
OUTPUT
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:

BASH
for h in web1 web2 db1; do ssh -F ssh_config $h "hostname; uptime -p"; done
OUTPUT
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:

INI
[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:

INI
# Which servers Ansible manages, in groups.
[web]
web1
web2

[db]
db1
  • ansible.cfg is Ansible's settings file. Here: where the inventory is, how to find Python on the servers (more on that below), and to give SSH the ssh_config file, so Ansible connects exactly the way the loop above did.
  • inventory.ini is the inventory: the list of servers Ansible manages, in groups in square brackets. web1 and web2 are in web; db1 is in db. Lesson 2 is all about inventories.

Ask Ansible which servers it knows:

BASH
ansible all --list-hosts
ansible web --list-hosts
OUTPUT
  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:

BASH
ansible all -m ping
OUTPUT
[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 ping picks the module. A one-off command like this, with no playbook, is an ad-hoc command.
  • The order isn't fixed: db1 answered 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 the interpreter_python = auto_silent line in ansible.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:

BASH
ansible all -m command -a "uptime -p"
ansible db -m command -a "free -h"
OUTPUT
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:

BASH
ansible all -m setup -a "filter=ansible_distribution*"
OUTPUT
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:

BASH
ansible web -m apt -a "name=tree state=present update_cache=true" --become
OUTPUT
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:

BASH
ansible web -m apt -a "name=tree state=present" --become
OUTPUT
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:

BASH
ansible web -m apt -a "name=tree state=absent" --become
ansible web -m apt -a "name=tree state=absent" --become
OUTPUT
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:

BASH
ansible web -m command -a "touch /tmp/marker"
OUTPUT
web1 | CHANGED | rc=0 >>

web2 | CHANGED | rc=0 >>

The file module manages files by state. But watch state=touch:

BASH
ansible web -m file -a "path=/tmp/marker state=touch"
OUTPUT
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:

BASH
ansible web -m file -a "path=/tmp/marker state=absent"
ansible web -m file -a "path=/tmp/marker state=absent"
OUTPUT
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:

BASH
ansible web3 -m ping
OUTPUT
[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:

BASH
vagrant halt db1
ansible all -m ping -o
OUTPUT
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:

BASH
vagrant up db1
vagrant ssh-config > ssh_config
ansible all -m ping -o
OUTPUT
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

ToRun
Install Ansible (Mac)brew install ansible
Start the practice serversvagrant up, in ansible-practice
Write their SSH settingsvagrant ssh-config > ssh_config
List the servers in a groupansible web --list-hosts
Check Ansible can reach themansible all -m ping
One line per serveransible all -m ping -o
Run a command everywhereansible all -m command -a "uptime -p"
See a server's factsansible db1 -m setup -a "filter=ansible_distribution*"
Make sure a package is installedansible web -m apt -a "name=tree state=present" --become
Make sure a file is goneansible web -m file -a "path=/tmp/marker state=absent"
Stop, or start, the serversvagrant 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

  1. What does it mean that Ansible is agentless?

  2. The same apt command, run twice, says CHANGED then SUCCESS. Why?

  3. Why does command -a 'uptime -p' report CHANGED?

  4. file with state=touch says CHANGED every run. Why isn't it idempotent?

  5. db1 is stopped. What does ansible all -m ping do?

  6. 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, file or ping, 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