SSD Nodes Learn Hosting plans →
How to do am Matt ConnorBy Matt Connor · Updated 2026-08-07

Ansible tutorial: set up your first VPS playbook

Install Ansible with pipx on Ubuntu 24.04, write inventory and harden a fresh VPS with your first playbook. Fix Permission denied and sudo errors too.

Wetin you dey build

One control machine wey get Ansible installed, plus one or more fresh Ubuntu 24.04 VPSes wey only get the stock image. By the end, you go get inventory file wey name your servers, ad-hoc ping wey prove say authentication dey work from end to end, and playbook wey run the complete new-VPS checklist as code: deploy user with your SSH key, hardened sshd, fail2ban, unattended upgrades, and firewall wey allow OpenSSH before e block everything else. Point am to one server or twenty. Run am twice and the second run no go change anything. Na the whole point be that.

After fifteen years of provisioning VPSes, I fit tell you the honest pattern: everybody dey set up the first five servers by hand, then dem go lose one weekend for the sixth because nobody remember wetin dem do for the first five. This guide go add more detail to the survey for managing multiple Linux servers, so pick am up when you catch yourself typing the same apt install for three terminals.

Wetin Ansible really be, for one paragraph

Ansible no need agent. No daemon dey to install for the servers wey e dey manage: the control machine go connect through normal SSH, copy small Python module go the target, run am, read the JSON wey e print, then delete am. The only thing target need na python3, and every standard Ubuntu image already get am. The important word na idempotent, and e get simple meaning: task dey describe a state, no be action. state: present for package mean “make sure say dem install am”, no be “run the installer”. If the state already dey correct, Ansible no go touch anything; e go report am as ok instead of changed. Na this property be the whole product. Na am make rerunning playbook safe, and safe reruns na wetin dey turn shell script into infrastructure.

Prerequisites, and the gotchas wey dey front

  • A control machine: your laptop or one small VPS. I assume say na Ubuntu 24.04 you dey use; 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 fit reach dem as root. Nothing go install for dem.
  • SSH key authentication to every target. Ansible authentication na exactly the same as your ssh command; if ssh root@host ask for password, Ansible go fail.
  • For Ubuntu 24.04, pip install ansible go fail with error: externally-managed-environment. Na deliberate distro policy be that, no be breakage. Use pipx.
  • YAML whitespace na syntax. Wrong indentation go produce mapping values are not allowed in this context, and any tab character anywhere go cause fatal error.
  • Keep one working SSH session open for each target while the playbook dey harden sshd. Every lockout wey I help customer recover from happen after dem close the last session “to test from clean”.

Step 1: install Ansible for the control machine with pipx, no be pip

The usual first thought na pip3 install ansible. For a fresh 24.04 image, this one go fail one step before, Command 'pip3' not found, but can be installed with: sudo apt install python3-pip, and if you install pip, you go only reach 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 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 talk true about wetin e dey do. The clean solution na pipx. E give Ansible im own isolated virtualenv and put the binaries for your PATH:

sudo apt update && sudo apt install -y pipx
pipx ensurepath
pipx install --include-deps ansible

Open new shell after pipx ensurepath so the PATH change go take effect. --include-deps no be decoration: the ansible package no ship any console scripts by itself, ansible, ansible-playbook, and the others na entry points from im ansible-core dependency. So, without the flag, pipx go refuse the install with No apps associated with package ansible or its dependencies. Also install the ansible package, no be bare ansible-core. The full package bundle the community collections, and this playbook dey use modules from two of dem (ansible.posix and community.general).

ansible --version

The correct result go start with line like ansible [core 2.19.x] and show the Python wey e dey run under. Any current core release dey okay for everything here. ansible: command not found instead mean say ~/.local/bin never dey your PATH, so open new shell, or source ~/.bashrc.

Na the complete install be that. The targets no get anything.

Step 2: SSH key access go every target

ssh-keygen -t ed25519 -C "ansible control"
ssh-copy-id root@10.0.0.10
ssh-copy-id root@10.0.0.20

Then prove am, once for each host:

ssh root@10.0.0.10 true && echo ok

That one line dey do two jobs: e confirm say key auth dey work without password, and e record the host key for known_hosts. Do am now, because Ansible dey show unrecorded host key as interactive prompt wey hide inside middle of run. E go look exactly like say e hang.

Step 3: inventory, INI first, YAML when e don grow

Inventory na text file wey dey list the machines Ansible fit touch. Create inventory.ini inside fresh project directory:

[vps]
web1 ansible_host=10.0.0.10
web2 ansible_host=10.0.0.20

[vps:vars]
ansible_user=root

web1 na alias wey you choose. Na wetin go show for output and 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 wey dey inside am; ansible_user na the user wey Ansible go log in as. Put ansible.cfg beside am, so you no go type -i again:

[defaults]
inventory = inventory.ini

Ansible dey read ansible.cfg from current directory. For the same inventory in YAML, save am as inventory.yml and point ansible.cfg to that name instead. You go prefer this once hosts get several variables each:

vps:
  hosts:
    web1:
      ansible_host: 10.0.0.10
    web2:
      ansible_host: 10.0.0.20
  vars:
    ansible_user: root

Dem equivalent. INI easier to check quickly when na two servers; YAML dey scale better when dem reach twenty. Choose one and stop worrying about am.

Step 4: ad-hoc commands, the green pong wey prove say everything dey work

ansible all -m ping

This one no be ICMP. The ping module na full rehearsal: SSH login, module copy, Python execution for the target, and cleanup. The correct result na green, with one block for each host:

web1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Green SUCCESS mean authentication, the Python interpreter, and the transport all dey work, so the playbook go work too. Red UNREACHABLE! mean the transport fail before any module run; the exact string and the fix dey for failure modes section below. You suppose sabi two more ad-hoc commands:

ansible all -a "uptime"
ansible all -m apt -a "update_cache=true upgrade=dist" --become

Use ad-hoc for one-off tasks and checks. Anything wey you go run twice belong for a playbook.

Step 5: the first playbook, the new-VPS checklist as code

Na dis everything wey you go do by hand for the 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: restarted

Na these lines you need understand instead of just copy:

Variables dey under vars: and you go reference dem with "{{ deploy_user }}". Put quote around the whole expression when value start with brace, otherwise YAML parser fit read am wrongly. lookup('file', ...) dey read your public key from the control machine when e dey run, so playbook no carry any key material.

The loop. loop: "{{ baseline_services }}" dey run the service task once for every item, and output dey show every item for im own line. Notice say apt task dey take the whole package list at once. One apt transaction dey faster and na the preferred pattern for packages. Use loops for modules wey genuinely dey work on one thing at a time.

The handler na the concept wey you need internalize. notify: Restart ssh no mean "restart ssh now". E queue the handler, wey go run once at the end of the play, and only if the notifying task actually report changed. If you run the playbook again tomorrow, the drop-in file don already correct, copy task go report ok, and sshd no go restart. The validate: line na the safety check for the trigger. sshd dey check the file before e replace the old one, so typo go make the task fail instead of breaking the daemon.

PermitRootLogin prohibit-password, no be no, deliberately. This playbook dey log in as root with key. prohibit-password dey stop password root logins but e go keep your own login working. After you don confirm say deploy user dey work (ssh deploy@10.0.0.10 sudo true, the plain address, because web1 na alias wey only Ansible know), change ansible_user=deploy for inventory and tighten am to no for a later run. Harden am in order wey no go lock you out.

The 00- prefix matter. For most keywords wey sshd support, e dey use the first occurrence wey e parse. Ubuntu's sshd_config include sshd_config.d/*.conf in lexical order before im own body. Ubuntu 24.04 cloud images already get 60-cloudimg-settings.conf for that directory. Providers wey enable password logins through cloud-init dey add 50-cloud-init.conf with PasswordAuthentication yes. Naming our own 00-hardening.conf make e sort first and override both.

Task order na firewall safety. Allow OpenSSH dey run before Enable ufw with deny policy. Ansible dey execute tasks strictly in the order wey dem list, so the hole dey exist before the wall go up. fail2ban no need extra configuration to be useful here. Im Ubuntu defaults dey monitor sshd out of the box. The guide fail2ban for Ubuntu 24.04 explain wetin the jails dey actually do and wetin you fit tune.

Step 6: dry run with --check, then run am for real

ansible-playbook site.yml --check

Check mode go connect, calculate wetin e for do, and e no change anything. Read the changed= count for the PLAY RECAP wey dey bottom; na the number of tasks wey for modify each host. Make we talk one important limit: check mode get structural limit anywhere later task depend on changes wey earlier task make. Ubuntu standard server image dey come with ufw already, so this playbook dry run dey pass clean. But for minimal image wey no get am, ufw tasks go fail for check mode, because check mode no ever install the package, and the module no get anything to call. Na dry run limitation be this, no be bug for your playbook. When the plan look correct:

ansible-playbook site.yml

Each task dey print one line for each host. Yellow changed mean say task go make change, green ok mean say no change dey needed, and recap suppose 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 na fact-gathering plus eight tasks plus the handler. Your changed fit differ from my own by one or two. Ubuntu standard image dey come with ufw and unattended-upgrades already, and fail2ban start by itself immediately apt install am. So task fit rightly report ok for the first run, because the state wey e declare don already dey in place. The numbers wey must be zero na unreachable and failed. One thing about become: true: e 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 this playbook install na exactly wetin keep -K comot from your command line. Without am, you go get Missing sudo password, wey we cover below.

Step 7: run am twice, see wetin idempotence look 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 drop by one because the handler wey no get notification never run. E no reinstall anything, sshd no restart, and e no touch ufw. Na this one make the playbook be audit tool as much as provisioner: add web3 to the inventory next month and run am again, the new box go build, while the old boxes go verify. Nonzero changed for box wey you never touch na drift, and e dey show say person edit by hand wetin suppose dey edited for the playbook.

From here, the pattern go build on itself. The next playbook wey make sense to write na one wey go set up WireGuard VPN for the same VPS and tighten the ufw rule so SSH go answer only through the tunnel; after that, write 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 then.

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
}

The SSH transport fail before any module run: ansible_user no correct, dem never copy the key go that host, or the wrong key dey get offered. Reproduce am with plain ssh root@10.0.0.10, then use ssh -v to see which keys dem 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, connect as non-root user, and that user need password for sudo. Either add -K (--ask-become-pass) for the command line, or give the user NOPASSWD sudoers entry. Na exactly why the playbook install one for deploy before you switch go am.

error: externally-managed-environment. You run pip against the system Python for Ubuntu 24.04. Step 1 don cover am: 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 context

Almost always, na indentation problem: key dey for wrong depth, or space no dey after colon. The line number wey e report point near the mistake, no be necessarily the mistake itself, so check the line above too. The related error found character '\t' that cannot start any token mean say tab enter the file; YAML no allow tabs. Make ansible-playbook site.yml --syntax-check become reflex before every run, and set your editor to use two-space indentation for YAML.

/usr/bin/python3: not found. E rare for standard Ubuntu 24.04 images, but common for minimal or netboot ones: module execution fail because 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 run the playbook again.

FAQ

I need install Ansible for the servers wey e dey manage?

No. Ansible no need agent: the control machine dey push small Python modules through SSH, run dem, then remove dem. Target machine only need python3 and SSH access, and stock Ubuntu images already get both. The only installation for this whole guide dey happen for your control machine.

Why Ansible dey talk "Permission denied (publickey)"?

The UNREACHABLE! block wey get Permission denied (publickey) mean say SSH authentication fail before Ansible run anything. Check say ansible_user for the inventory match the account wey you actually set up, say you run ssh-copy-id to that host, and say plain ssh user@host fit log in without password. Anything wey fix the plain ssh command go fix Ansible too, because dem dey use the same transport.

Wetin idempotent mean for Ansible?

A task dey declare the state wey you want, like "this package dey present" or "this line dey inside this file", instead of action wey e suppose perform. If the state already dey correct, Ansible no do anything and report ok instead of changed. Na why running playbook two times go show changed=0 the second time, and why rerun dey safe for audit instead of risky re-installation.

I suppose use pip or pipx to install Ansible for Ubuntu 24.04?

pipx. Ubuntu 24.04 mark the system Python as externally managed, so pip install ansible fail with error: externally-managed-environment by design. pipx install --include-deps ansible put Ansible inside isolated virtualenv and expose ansible, ansible-playbook, and the rest for your PATH cleanly.

Wetin be the difference between the ansible and ansible-core packages?

ansible-core na the engine plus only the ansible.builtin modules. The ansible package bundle core with the curated community collections, including ansible.posix (the authorized_key module) and community.general (the ufw module), and this guide use both. Start with the full package; reduce am to core plus collections wey you pick by hand only when you get reason.