Ansible tutorial: your first playbook on a VPS
Install Ansible with pipx on Ubuntu 24.04, write an inventory and a first playbook that hardens a fresh VPS, plus fixes for Permission denied and sudo errors.
What you are building
One control machine with Ansible installed, and one or more fresh Ubuntu 24.04 VPSes with nothing on them but the stock image. By the end you will have an inventory file that names your servers, an ad-hoc ping that proves authentication works end to end, and a playbook that runs the whole new-VPS checklist as code: a deploy user with your SSH key, hardened sshd, fail2ban, unattended upgrades, and a firewall that allows OpenSSH before it denies everything else. Point it at one server or twenty. Run it twice and the second run changes nothing — that is the whole point.
After fifteen years of provisioning VPSes I can tell you the honest pattern: everyone sets up the first five servers by hand, then loses a weekend on the sixth because nobody remembers what they did to the first five. This guide deepens the survey in managing multiple Linux servers — pick it up the day you catch yourself typing the same apt install into three terminals.
What Ansible actually is, in one paragraph
Ansible is agentless. There is no daemon to install on the servers it manages: the control machine connects over ordinary SSH, copies a small Python module to the target, executes it, reads the JSON it prints, and deletes it. The only thing a target needs is python3, which every stock Ubuntu image already has. The word that matters is idempotent, and it means something plain: a task describes a state, not an action. state: present for a package means "make sure this is installed", not "run the installer". If the state already holds, Ansible touches nothing and reports it as ok instead of changed. That property is the entire product — it is what makes rerunning a playbook safe, and safe reruns are what turn a shell script into infrastructure.
Prerequisites, and the gotchas up front
- A control machine: your laptop or a small VPS. I assume Ubuntu 24.04; macOS works identically once pipx is installed from Homebrew.
- One or more target VPSes running Ubuntu 24.04 on KVM, reachable as root. Nothing gets installed on them.
- SSH key authentication to every target. Ansible is exactly as authenticated as your
sshcommand — ifssh root@hostprompts for a password, Ansible fails. - On Ubuntu 24.04,
pip install ansibledies witherror: externally-managed-environment. That is deliberate distro policy, not breakage. Use pipx. - YAML whitespace is syntax. A wrong indent produces
mapping values are not allowed in this context, and a tab character anywhere is fatal. - Keep a working SSH session open on each target while the playbook hardens sshd. Every lockout I have helped a customer recover from involved closing the last session "to test from clean".
Step 1: install Ansible on the control machine with pipx, not pip
The classic instinct is pip3 install ansible. On a truly fresh 24.04 image that fails one step early — Command 'pip3' not found, but can be installed with: sudo apt install python3-pip — and installing pip only promotes you to the real wall:
pip3 install ansible
error: externally-managed-environment
× This environment is externally managed
╰─> To install Python packages system-wide, try apt install
python3-xyz, where xyz is the package you are trying to
install.
Ubuntu 24.04 marks the system Python as externally managed (PEP 668) so pip cannot fight apt over the same files. Do not reach for --break-system-packages; the flag is named honestly. The clean answer is pipx, which gives Ansible its own isolated virtualenv and puts the binaries on your PATH:
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible
Open a new shell after pipx ensurepath so the PATH change lands. --include-deps is not decoration: the ansible package ships no console scripts of its own — ansible, ansible-playbook, and the rest are entry points of its ansible-core dependency — so without the flag pipx refuses the install with No apps associated with package ansible or its dependencies. And install the ansible package, not bare ansible-core — the full package bundles the community collections, and this playbook uses modules from two of them (ansible.posix and community.general).
ansible --version
The correct result opens with a line like ansible [core 2.19.x] and names the Python it runs under; any current core release is fine for everything here. ansible: command not found instead means ~/.local/bin is not on your PATH yet — new shell, or source ~/.bashrc.
That is the entire install. The targets get nothing.
Step 2: SSH key access to every target
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id [email protected]
ssh-copy-id [email protected]
Then prove it, once per host:
ssh [email protected] true && echo ok
That one line does two jobs: it confirms key auth works without a password, and it records the host key in known_hosts. Do it now, because Ansible surfaces an unrecorded host key as an interactive prompt buried in the middle of a run, which reads exactly like a hang.
Step 3: the inventory — INI first, YAML when it grows
The inventory is a text file listing the machines Ansible may touch. Create inventory.ini in a fresh project directory:
[vps]
web1 ansible_host=203.0.113.10
web2 ansible_host=203.0.113.20
[vps:vars]
ansible_user=root
web1 is an alias you choose — it is what appears in output and what you target with --limit web1. ansible_host is the real address. [vps] is a group, and [vps:vars] sets variables for every host in it; ansible_user is who Ansible logs in as. Next to it, an ansible.cfg so you never type -i again:
[defaults]
inventory = inventory.ini
Ansible reads ansible.cfg from the current directory. The same inventory in YAML — save it as inventory.yml and point ansible.cfg at that name instead — is what you will prefer once hosts carry several variables each:
vps:
hosts:
web1:
ansible_host: 203.0.113.10
web2:
ansible_host: 203.0.113.20
vars:
ansible_user: root
They are equivalent. INI is easier to eyeball at two servers; YAML scales better at twenty. Pick one and stop thinking about it.
Step 4: ad-hoc commands — the green pong that proves everything
ansible all -m ping
This is not ICMP. The ping module is a full dress rehearsal: SSH login, module copy, Python execution on the target, cleanup. The correct result is green, one block per host:
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}
Green SUCCESS means authentication, the Python interpreter, and the transport all work — the playbook will too. Red UNREACHABLE! means the transport failed before any module ran; the exact string and the fix are in the failure modes section below. Two more ad-hoc commands worth knowing:
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --become
Ad-hoc is for one-offs and checks. Anything you would run twice belongs in a playbook.
Step 5: the first playbook — the new-VPS checklist as code
This is everything you would do by hand in the first ten minutes on a new server. Save it as site.yml:
---
- name: Baseline a fresh Ubuntu VPS
hosts: vps
become: true
vars:
deploy_user: deploy
deploy_pubkey: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
baseline_packages:
- fail2ban
- unattended-upgrades
- ufw
baseline_services:
- fail2ban
- unattended-upgrades
tasks:
- name: Create the deploy user
ansible.builtin.user:
name: "{{ deploy_user }}"
groups: sudo
append: true
shell: /bin/bash
- name: Install the deploy user's SSH key
ansible.posix.authorized_key:
user: "{{ deploy_user }}"
key: "{{ deploy_pubkey }}"
- name: Passwordless sudo for the deploy user
ansible.builtin.copy:
dest: /etc/sudoers.d/deploy
content: "{{ deploy_user }} ALL=(ALL) NOPASSWD:ALL\n"
mode: "0440"
validate: /usr/sbin/visudo -cf %s
- name: Install baseline packages
ansible.builtin.apt:
name: "{{ baseline_packages }}"
state: present
update_cache: true
- name: Enable and start baseline services
ansible.builtin.service:
name: "{{ item }}"
state: started
enabled: true
loop: "{{ baseline_services }}"
- name: Harden sshd with a drop-in
ansible.builtin.copy:
dest: /etc/ssh/sshd_config.d/00-hardening.conf
content: |
PasswordAuthentication no
KbdInteractiveAuthentication no
PermitRootLogin prohibit-password
X11Forwarding no
mode: "0644"
validate: /usr/sbin/sshd -t -f %s
notify: Restart ssh
- name: Allow OpenSSH through ufw
community.general.ufw:
rule: allow
name: OpenSSH
- name: Enable ufw with default deny
community.general.ufw:
state: enabled
policy: deny
handlers:
- name: Restart ssh
ansible.builtin.service:
name: ssh
state: restarted
The lines worth understanding rather than copying:
Variables live under vars: and are referenced with "{{ deploy_user }}" — quote the whole expression when a value starts with a brace, or the YAML parser misreads it. The lookup('file', ...) reads your public key off the control machine at runtime, so the playbook carries no key material.
The loop. loop: "{{ baseline_services }}" runs the service task once per item, and the output shows each item on its own line. Note that the apt task takes the whole package list in one go instead — one apt transaction is faster and is the preferred pattern for packages; loops are for modules that genuinely act on one thing at a time.
The handler is the concept to internalize. notify: Restart ssh does not mean "restart ssh now". It queues the handler, which runs once at the end of the play, and only if the notifying task actually reported changed. Rerun the playbook tomorrow: the drop-in file is already correct, the copy task reports ok, and sshd is never bounced. The validate: line is the safety on the trigger — sshd checks the file before it replaces the old one, so a typo fails the task instead of breaking the daemon.
PermitRootLogin prohibit-password, not no — deliberately. This playbook logs in as root with a key. prohibit-password shuts off password root logins while keeping yours alive. Once the deploy user is proven (ssh [email protected] sudo true — the plain address, since web1 is an alias only Ansible knows), switch ansible_user=deploy in the inventory and tighten it to no in a later run. Harden in an order that cannot strand you.
The 00- prefix matters. For most keywords sshd honors the first occurrence it parses, and Ubuntu's sshd_config includes sshd_config.d/*.conf in lexical order before its own body. Ubuntu 24.04 cloud images already ship a 60-cloudimg-settings.conf in that directory, and providers that enable password logins through cloud-init add a 50-cloud-init.conf with PasswordAuthentication yes; naming ours 00-hardening.conf makes it sort first and win over both.
Task order is the firewall safety. Allow OpenSSH runs before Enable ufw with a deny policy — Ansible executes tasks strictly in the order listed, so the hole exists before the wall goes up. fail2ban needs no configuration to be useful here; its Ubuntu defaults watch sshd out of the box, and what the jails actually do — and what to tune — is covered in the fail2ban on Ubuntu 24.04 guide.
Step 6: dry run with --check, then run it for real
ansible-playbook site.yml --check
Check mode connects, computes what it would do, and changes nothing. Read the changed= count in the PLAY RECAP at the bottom — that is the number of tasks that would modify each host. One honest caveat: check mode has a structural limit wherever a later task depends on an earlier task's changes. Ubuntu's standard server image preships ufw, so this playbook dry-runs clean — but on a minimal image without it, the ufw tasks fail in check mode, because check mode never actually installed the package and the module then has nothing to call. That is a limit of dry runs, not a bug in your playbook. When the plan looks right:
ansible-playbook site.yml
Each task prints a line per host — yellow changed, green ok — and the recap should read:
PLAY RECAP *********************************************************************
web1 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=10 changed=9 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Ten ok is fact-gathering plus eight tasks plus the handler. Your changed is allowed to differ from mine by one or two: Ubuntu's standard image preships ufw and unattended-upgrades, and fail2ban starts itself the moment apt installs it, so a task can legitimately report ok on its very first run — the state it declares already held. The numbers that must be zero are unreachable and failed. One note on become: true: it is a formality while you connect as root, but the moment you flip ansible_user to deploy, sudo is real — and the NOPASSWD sudoers file this playbook installs is exactly what keeps -K off your command line. Without it you get Missing sudo password, covered below.
Step 7: run it twice — what idempotence looks like
Run the same command again immediately:
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0, and ok dropped by one because the un-notified handler never ran. Nothing was reinstalled, sshd was not restarted, ufw was not touched. This is what makes the playbook an audit as much as a provisioner: add web3 to the inventory next month and rerun — the new box gets built, the old boxes get verified. A nonzero changed on a box you have not touched is drift, and it tells you someone edited by hand what should have been edited in the playbook.
From here the pattern compounds. The next playbook worth writing lays down a WireGuard VPN on the same VPS and tightens the ufw rule so SSH answers only on the tunnel; after that, one that installs Docker and Compose on every app server. When site.yml passes three screens, split it into roles — but not before.
Failure modes, with the strings you will see
UNREACHABLE with Permission denied.
web1 | UNREACHABLE! => {
"changed": false,
"msg": "Failed to connect to the host via ssh: [email protected]: Permission denied (publickey).",
"unreachable": true
}
The SSH transport failed before any module ran: ansible_user is wrong, the key was never copied to that host, or the wrong key is being offered. Reproduce with plain ssh [email protected], then ssh -v to see which keys were offered. If password SSH works but Ansible does not, you skipped ssh-copy-id.
Missing sudo password.
web1 | FAILED! => {
"msg": "Missing sudo password"
}
You set become: true, connected as a non-root user, and that user needs a password for sudo. Either add -K (--ask-become-pass) to the command line, or give the user a NOPASSWD sudoers entry — which is precisely why the playbook installs one for deploy before you ever switch to it.
error: externally-managed-environment. You ran pip against the system Python on Ubuntu 24.04. Covered in step 1: pipx, not pip, and not --break-system-packages.
mapping values are not allowed in this context.
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this context
Almost always indentation: a key at the wrong depth, or a missing space after a colon. The reported line number points near the mistake, not at it — check the line above too. Its cousin found character '\t' that cannot start any token means a tab crept in; YAML forbids them. Make ansible-playbook site.yml --syntax-check a reflex before every run, and set your editor to two-space indentation for YAML.
/usr/bin/python3: not found. Rare on standard Ubuntu 24.04 images, common on minimal or netboot ones: module execution fails because the target has no Python. Bootstrap it with the raw module, the one module that needs nothing on the far side: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, then rerun the playbook.
FAQ
Do I need to install Ansible on the servers it manages?
No. Ansible is agentless: the control machine pushes small Python modules over SSH, runs them, and removes them. A target needs only python3 and SSH access, both of which stock Ubuntu images already have. The only install in this entire guide happens on your control machine.
Why does Ansible say "Permission denied (publickey)"?
The UNREACHABLE! block with Permission denied (publickey) means SSH authentication failed before Ansible ran anything. Check that ansible_user in the inventory matches the account you actually set up, that you ran ssh-copy-id to that host, and that plain ssh user@host logs in without a password. Whatever fixes the plain ssh command fixes Ansible, because they are the same transport.
What does idempotent mean in Ansible?
A task declares a desired state — "this package is present", "this line is in this file" — rather than an action to perform. If the state already holds, Ansible does nothing and reports ok instead of changed. That is why running a playbook twice shows changed=0 the second time, and why a rerun is a safe audit rather than a risky re-install.
Should I use pip or pipx to install Ansible on Ubuntu 24.04?
pipx. Ubuntu 24.04 marks the system Python as externally managed, so pip install ansible fails with error: externally-managed-environment by design. pipx install --include-deps ansible puts Ansible in an isolated virtualenv and exposes ansible, ansible-playbook, and the rest on your PATH cleanly.
What is the difference between the ansible and ansible-core packages?
ansible-core is the engine plus only the ansible.builtin modules. The ansible package bundles core with the curated community collections — including ansible.posix (the authorized_key module) and community.general (the ufw module), both used in this guide. Start with the full package; slim down to core plus hand-picked collections only when you have a reason to.