Three files and one command get you a working Ansible setup: install Ansible on your control machine, write a one-line inventory.ini listing the host you want to manage, write a short playbook.yml, then run it:
ansible-playbook -i inventory.ini playbook.ymlAnsible getting started is genuinely this short because Ansible is agentless. There is nothing to install on the machines you manage. The control node talks to them over plain SSH and runs short-lived Python, so a fresh Linux box with an SSH key already on it is ready to be configured with no prep. This is the whole loop, from install to a playbook that actually changes a remote host.
Install Ansible on the control node
You only install Ansible on the one machine you drive everything from (your laptop, a CI runner, a jump box). The managed hosts get nothing.
The cleanest install is pipx, which gives you a current Ansible in its own isolated virtualenv. This is the route the Ansible docs recommend, and it sidesteps the externally-managed-environment error you now hit if you try a plain pip install on Debian 12+, Ubuntu 23.04+, Fedora, or Homebrew Python (PEP 668 marks the system Python as off-limits to pip):
pipx install --include-deps ansibleOr use the package manager if you prefer it pinned to the distro (older, but zero setup):
# Debian / Ubuntu
sudo apt install ansible
# Fedora / RHEL
sudo dnf install ansible
# macOS
brew install ansibleConfirm it landed:
ansible --versionansible [core 2.18.3]
config file = None
python version = 3.12.3
jinja version = 3.1.4The ansible command runs single ad-hoc tasks; ansible-playbook runs the YAML files. Both come from the same install.
Write an inventory
The inventory lists the hosts Ansible can touch, optionally in groups. The simplest possible one is a single line. Create inventory.ini:
[web]
192.0.2.10 ansible_user=techearl[web] is a group name. The line under it is a host, and ansible_user=techearl tells Ansible which SSH user to connect as. If your SSH key is already on that box for the techearl user (see creating a user on Linux if it is not), this is all the connection detail Ansible needs.
Ansible uses the same SSH that your terminal does, so confirm a plain ssh techearl@192.0.2.10 logs in without a password prompt first; if that asks for one, fix the key before Ansible will get anywhere. Once raw SSH is clean, prove the connection from Ansible's side before writing any playbook. The ping module is not an ICMP ping; it confirms Ansible can SSH in and run Python on the far side:
ansible web -i inventory.ini -m ping192.0.2.10 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}pong means SSH worked and Python is present on the host. If you see a permission or unreachable error instead, fix SSH first; the playbook will not get any further than this did.
Write your first playbook
A playbook is a YAML list of plays. Each play maps a group of hosts to an ordered list of tasks, and each task calls one module. Create playbook.yml:
---
- name: Set up the web host
hosts: web
become: true
tasks:
- name: Install nginx
ansible.builtin.package:
name: nginx
state: present
- name: Make sure nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: true
- name: Drop in a holding-page index
ansible.builtin.copy:
content: "Configured by Ansible.\n"
dest: /usr/share/nginx/html/index.html
mode: "0644"A few things worth naming here. become: true is Ansible's privilege escalation; it runs the tasks through sudo on the host, which you need to install packages and write under /usr/share. That is the same idea as running a command as another user on Linux, just expressed declaratively. The ansible.builtin.package module is distro-agnostic: it dispatches to apt, dnf, or whatever the host actually uses, so the same play installs nginx on Ubuntu and Fedora alike. Every task has a name because those names are what you read in the run output.
Run it
ansible-playbook -i inventory.ini playbook.ymlPLAY [Set up the web host] *****************************************************
TASK [Gathering Facts] *********************************************************
ok: [192.0.2.10]
TASK [Install nginx] ***********************************************************
changed: [192.0.2.10]
TASK [Make sure nginx is running and enabled] **********************************
changed: [192.0.2.10]
TASK [Drop in a holding-page index] ********************************************
changed: [192.0.2.10]
PLAY RECAP *********************************************************************
192.0.2.10 : ok=4 changed=3 unreachable=0 failed=0 skipped=0changed means Ansible did something; ok means the state was already correct. That distinction is the whole point of Ansible, and the reason the next section matters.
Idempotence: why running it twice is safe
Run the exact same command again and the recap flips:
PLAY RECAP *********************************************************************
192.0.2.10 : ok=4 changed=0 unreachable=0 failed=0 skipped=0changed=0. Nothing was touched the second time, because the host already matched the desired state: nginx is installed, the service is running, the file has the right content. This is idempotence. Ansible modules describe the end state you want, then do only the work needed to reach it. You can run the same playbook on every deploy without fear of stacking duplicate changes, which is what separates configuration management from a shell script that blindly re-runs every line.
The honest caveat: idempotence is a property of the modules, not a guarantee Ansible enforces for you. ansible.builtin.package and ansible.builtin.service are idempotent. The ansible.builtin.command and ansible.builtin.shell modules are not; they run whatever you give them every time and report changed on every run unless you add a creates: or changed_when: guard. Reach for the purpose-built module before you reach for shell.
Ad-hoc commands vs playbooks
Not everything needs a playbook. For a one-off ("restart nginx everywhere", "what is the uptime"), an ad-hoc command is faster.
| Ad-hoc command | Playbook | |
|---|---|---|
| Invocation | ansible web -m service -a "name=nginx state=restarted" | ansible-playbook playbook.yml |
| Good for | One throwaway task, quick checks | Repeatable, version-controlled setup |
| Stored | Nothing, it lives in your shell history | A YAML file you commit to git |
| Multiple steps | One module per call | Many tasks, ordered, in one run |
| Idempotent | Same module rules apply | Same module rules apply |
The rule I use: if I would want to run it again next month, or if anyone else would need to know what I did, it goes in a playbook. Everything else is ad-hoc.
When not to reach for Ansible
Ansible is push-based and runs over SSH on demand. That is the wrong shape for a few jobs. If you need machines to continuously enforce their own state without a control node poking them, a pull-based agent model fits better. If you are provisioning the infrastructure itself (creating the VMs, networks, and load balancers in a cloud), that is a job for an infrastructure-as-code tool, and Ansible configures the boxes after they exist rather than creating them. And for a single one-off command on one machine you already have a shell on, SSH and a script are simply less ceremony. Ansible earns its keep when you have more than one host, want the result to be repeatable, and want the desired state written down.
See also
- How to create a user on Linux: set up the SSH-reachable account Ansible connects as before you point an inventory at a fresh box.
- Run a command as another user on Linux: the
sudoand user-switching model behind Ansible'sbecome:privilege escalation.
Sources
Authoritative references this article was fact-checked against.
- Getting started with Ansible (official documentation)docs.ansible.com
- Intro to playbooks (Ansible documentation)docs.ansible.com
- Installing Ansible (official installation guide)docs.ansible.com





