how to use Ansible for VPS setup
Learn how to use pipx to install Ansible on Ubuntu 24.04. We cover inventory setup, playbook creation, and how to fix Permission denied or sudo errors.
Wetin you dey build
One control machine wey Ansible dey, and one or more brand new Ubuntu 24.04 VPSes wey empty, only stock image dey inside dem. By di end, you go get inventory file wey list all your servers, one ad-hoc ping wey go show say authentication dey work well, and one playbook wey go run di whole new-VPS checklist as code: one deploy user wey get your SSH key, hardened sshd, fail2ban, unattended upgrades, and one firewall wey go allow OpenSSH before e block everything else. You fit point am at one server or twenty. Run am twice, and di second run no go change anything — na dat one be di main point.
After fifteen years of provisioning VPSes, I fit tell you di real pattern: everybody dey set up di first five servers by hand, den dem dey lose one weekend for di sixth one because nobody remember wetin dem do for di first five. Dis guide dey expand di survey for managing multiple Linux servers — pick am up di day you catch yourself dey type di same apt install for three terminals.
Wetin Ansible be, for one paragraph
Ansible no dey use agent. You no need to install any daemon for the servers wey you wan manage: the control machine go connect through ordinary SSH, copy small Python module to the target, run am, read the JSON wey e print, and delete am. The only thing the target need na python3, wey every stock Ubuntu image already get. The word wey important na idempotent, and e mean something simple: one task dey describe state, no be just action. state: present for one package means "make sure say this one dey installed", no be "run the installer". If the state don already dey so, Ansible no go touch anything and e go report am as ok instead of changed. Na that property be the whole product — na wetin make am safe to rerun playbook, and safe rerun na wetin turn shell script to infrastructure.
Prerequisites, and the gotchas up front
- One control machine: your laptop or one small VPS. I assume Ubuntu 24.04; macOS go work the same way once you install pipx from Homebrew.
- One or more target VPSes wey dey run Ubuntu 24.04 on KVM, and you must fit reach dem as root. No thing go dey install for dem.
- SSH key authentication for every target. Ansible go follow the same authentication wey your
sshcommand dey use — ifssh root@hostask for password, Ansible go fail. - For Ubuntu 24.04,
pip install ansiblego die witherror: externally-managed-environment. Na deliberate distro policy, e no be breakage. Use pipx. - YAML whitespace na syntax. If you use wrong indent, you go get
mapping values are not allowed in this context, and if you use tab character anywhere, e go fail. - Keep one working SSH session open for every target while the playbook dey harden sshd. Every time I don help customer recover from lockout, e na because dem close the last session "to test from clean".
Step 1: install Ansible on the control machine with pipx, not pip
The normal way to do am na pip3 install ansible. If you use fresh 24.04 image wey don fail one step before — Command 'pip3' not found, but can be installed with: sudo apt install python3-pip — and you try install pip, you go meet big wahala:
pip3 install ansibleerror: 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 don mark system Python as externally managed (PEP 668) so pip no fit fight apt over the same files. No use --break-system-packages; the flag name na honest truth. The clean way na pipx, wey go give Ansible its own isolated virtualenv and put the binaries for your PATH:
sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansibleOpen new shell after you finish pipx ensurepath so the PATH change go work. --include-deps no be decoration: the ansible package no get console scripts for inside — ansible, ansible-playbook, and the rest na entry points for its ansible-core dependency — so if you no use the flag, pipx go refuse the install with No apps associated with package ansible or its dependencies. And install the ansible package, no be bare ansible-core — the full package get community collections inside, and this playbook use modules from two of dem (ansible.posix and community.general).
ansible --versionIf everything correct, the result go start with line like ansible [core 2.19.x] and e go show the Python wey e dey run; any current core release go work fine for everything here. ansible: command not found mean say ~/.local/bin no dey on your PATH yet — open new shell, or use source ~/.bashrc.
Na so the install end. The targets no go get anything.
Step 2: SSH key access to every target
ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20Then prove am, once for every host:
ssh root@10.0.0.10 true && echo okThat one line do two jobs: e confirm say key auth dey work without password, and e save the host key for known_hosts. Do am now, because if Ansible see host key wey no dey record, e go show interactive prompt for middle of run. Dis one dey look exactly like say the system hang.
Step 3: the inventory — use INI first, switch to YAML when e grow
Inventory na text file wey list all the machines wey Ansible fit touch. Create inventory.ini inside new project directory:
[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20
[vps:vars]
ansible_user=rootweb1 na alias wey you choose — na wetin go show for output and na wetin you go target with --limit web1. ansible_host na the real address. [vps] na group, and [vps:vars] dey set variables for every host inside that group; ansible_user na the user wey Ansible go use log in. Next to am, put ansible.cfg so you no go need type -i again:
[defaults]
inventory = inventory.iniAnsible dey read ansible.cfg from the current directory. The same inventory for YAML format — save am as inventory.yml and point ansible.cfg to that name instead — na wetin you go prefer once hosts start to carry many variables:
vps:
hosts:
web1:
ansible_host: 10.0.0.10
web2:
ansible_host: 10.0.0.20
vars:
ansible_user: rootDem be the same thing. INI easy to check when you get only two servers; YAML dey work better when you get twenty. Pick one and no dey think about am again.
Step 4: ad-hoc commands — the green pong wey prove everything
ansible all -m pingDis one no be ICMP. Dis ping module na full dress rehearsal: SSH login, module copy, Python execution for target, and cleanup. Correct result na green, one block for every host:
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3"
},
"changed": false,
"ping": "pong"
}Green SUCCESS mean say authentication, Python interpreter, and transport all dey work — playbook go work too. Red UNREACHABLE! mean say transport fail before any module run; you fit see exact string and how to fix am for failure modes section below. Two more ad-hoc commands wey important to know:
ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --becomeAd-hoc na for one-off tasks and checks. Anything wey you wan run twice, put am inside playbook.
Step 5: the first playbook — the new-VPS checklist as code
Dis na everything wey you go do by hand for first ten minutes for new server. Save am 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: restartedDis lines na dem wey you must understand instead of just to copy:
Variables dey live under vars: and you go use "{{ deploy_user }}" take call dem — put quote for the whole expression if value start with brace, otherwise YAML parser go misread am. lookup('file', ...) go read your public key from the control machine for runtime, so playbook no go carry any key material.
The loop. loop: "{{ baseline_services }}" dey run di service task once for every item, and di output go show each item for single line. Note say di apt task take di whole package list for one go — one apt transaction faster and na di better way for packages; loops na for modules wey dey act for one thing at a time.
The handler na di concept wey you must sabi. notify: Restart ssh no mean "restart ssh now". E dey queue di handler, wey go run once for end of di play, and only if di notifying task report changed. Rerun di playbook tomorrow: di drop-in file don correct already, di copy task go report ok, and sshd no go bounce. validate: line na di safety for di trigger — sshd go check di file before e replace di old one, so if typo dey, di task go fail instead of to break di daemon.
PermitRootLogin prohibit-password, no be no — deliberately. Dis playbook dey log in as root with key. prohibit-password dey shut off password root logins while e dey keep your own alive. Once you don confirm di deploy user (ssh deploy@10.0.0.10 sudo true — di plain address, because web1 na alias wey only Ansible sabi), change ansible_user=deploy for di inventory and tighten am to no for later run. Harden am for order wey no go lock you out.
Di 00- prefix dey matter. For most keywords, sshd dey honor di first occurrence wey e parse, and Ubuntu's sshd_config get sshd_config.d/*.conf for lexical order before its own body. Ubuntu 24.04 cloud images don carry 60-cloudimg-settings.conf for dat directory already, and providers wey dey enable password logins through cloud-init dey add 50-cloud-init.conf with PasswordAuthentication yes; to name ours 00-hardening.conf na to make e sort first and win over dem both.
Task order na di firewall safety. Allow OpenSSH dey run before Enable ufw with a deny policy — Ansible dey execute tasks strictly for di order wey dem list, so di hole go dey before di wall go up. fail2ban no need configuration to start to work well for here; Ubuntu defaults dey watch sshd out of di box, and wetin di jails dey actually do — and wetin you go tune — dey inside fail2ban on Ubuntu 24.04 guide.
Step 6: dry run with --check, then run it for real
ansible-playbook site.yml --checkCheck mode dey connect, e go compute wetin e go do, but e no go change anything. Look the changed= count for the PLAY RECAP for bottom — dat na the number of tasks wey go modify each host. One thing wey you must know: check mode get one limit if later task depend on wetin earlier task suppose change. Ubuntu standard server image dey come with ufw pre-installed, so dis playbook dry-run go clean — but if you use minimal image wey no get am, the ufw tasks go fail for check mode, because check mode no dey actually install package, so module no go get anything to call. Dat na how dry runs dey work, e no be bug for your playbook. If everything look correct:
ansible-playbook site.ymlEach task go print one line for each host — yellow changed, green ok — and the recap for look like dis:
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=0Ten ok na fact-gathering plus eight tasks plus the handler. Your changed fit different from mine by one or two: Ubuntu standard image dey come with ufw and unattended-upgrades, and fail2ban dey start itself as soon apt install am, so task fit report ok for first run — dat na because state wey e dey declare don already dey there. The numbers wey must be zero na unreachable and failed. One thing about become: true: na just formality while you connect as root, but once you change ansible_user to deploy, sudo become real — and the NOPASSWD sudoers file wey dis playbook install na wetin dey keep -K off your command line. Without am, you go see Missing sudo password, we go explain am below.
Step 7: run am twice — wetin idempotence be
Run di same command again immediately:
web1 : ok=9 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed=0, and ok drop by one because di un-notified handler no run. Dem no reinstall anything, dem no restart sshd, and dem no touch ufw. Dis na wetin make di playbook be audit as much as e be provisioner: add web3 to di inventory next month and rerun — di new box go build, but dem go verify di old boxes. If you see non-zero changed for box wey you no touch, dat na drift, and e mean say someone edit wetin playbook suppose edit by hand.
From here, di pattern go continue. Di next playbook wey worth to write go set up WireGuard VPN on di same VPS and tighten di ufw rule so SSH go only answer for di tunnel; after dat, one wey go install Docker and Compose for every app server. When site.yml pass three screens, split am into roles — but no do am before dat.
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: root@10.0.0.10: Permission denied (publickey).",
"unreachable": true
}SSH transport fail before any module start to run: ansible_user dey wrong, dem never copy the key to that host, or dem dey use wrong key. You fit see this one with plain ssh root@10.0.0.10, then use ssh -v see which keys dem dey offer. If password SSH dey work but Ansible no dey work, you skip ssh-copy-id.
Missing sudo password.
web1 | FAILED! => {
"msg": "Missing sudo password"
}You set become: true, you connect as non-root user, and that user need password for sudo. You fit add -K (--ask-become-pass) to the command line, or give the user NOPASSWD sudoers entry — na why the playbook dey install one for deploy before you even switch to am.
error: externally-managed-environment. You run pip against the system Python for Ubuntu 24.04. We don talk am for step 1: use pipx, no be pip, and no be --break-system-packages.
mapping values are not allowed in this context.
ERROR! Syntax Error while loading YAML.
mapping values are not allowed in this contextMostly na indentation wahala: key dey at wrong depth, or space missing after colon. The line number wey dem show dey near the mistake, no be the mistake itself — check the line wey dey above am too. The cousin of this one, found character '\t' that cannot start any token, mean say tab don creep inside; YAML no dey allow them. Make ansible-playbook site.yml --syntax-check become your habit before every run, and set your editor to use two-space indentation for YAML.
/usr/bin/python3: not found. E dey rare for standard Ubuntu 24.04 images, but e dey common for minimal or netboot ones: module execution fail because the target no get Python. Bootstrap am with the raw module, the only module wey no need anything for the far side: ansible all -m raw -a "apt-get update && apt-get install -y python3" --become, then rerun the playbook.
FAQ
I need to install Ansible for all the servers wey I wan manage?
No. Ansible na agentless tool: control machine dey push small Python modules over SSH, run dem, then delete dem. Target machine only need python3 and SSH access, and stock Ubuntu images already get both of dem. Na only your control machine wey you go need to install anything for this whole guide.
Why Ansible dey talk "Permission denied (publickey)"?
That UNREACHABLE! block wey get Permission denied (publickey) mean say SSH authentication fail before Ansible even start work. Check if ansible_user for your inventory match the account wey you set up, make sure say you don run ssh-copy-id to that host, and make sure say plain ssh user@host dey log in without password. Anything wey go fix the plain ssh command go fix Ansible too, because dem na the same transport.
Wetin "idempotent" mean for Ansible?
Task dey talk about how things suppose be — like "this package must dey" or "this line must dey inside this file" — instead of just telling am wetin to do. If the state don already dey so, Ansible no go do anything and e go report ok instead of changed. Na why if you run playbook twice, e go show changed=0 the second time, and why rerun dey safe for audit instead of risky re-install.
I suppose use pip or pipx to install Ansible for Ubuntu 24.04?
pipx. Ubuntu 24.04 don mark system Python as externally managed, so pip install ansible go fail with error: externally-managed-environment by design. pipx install --include-deps ansible dey put Ansible inside isolated virtualenv and e go expose ansible, ansible-playbook, and the rest for your PATH well.
Wetin be difference between ansible and ansible-core packages?
ansible-core na the engine plus only the ansible.builtin modules. The ansible package dey bundle core with community collections — including ansible.posix (the authorized_key module) and community.general (the ufw module), wey we use for this guide. Start with the full package; only reduce am to core plus hand-picked collections when you get real reason.