If you manage more than one Ubuntu server, you have probably typed the same commands over and over: apt update, install Nginx, copy a config file, restart a service. Ansible ends that repetition. It is an open-source automation tool that configures servers over SSH, with no agent to install on the machines it manages. You describe the desired state of a server in a simple YAML file called a playbook, and Ansible makes it so — on one server or a hundred, identically every time.

In this guide you will install Ansible on an Ubuntu control machine, connect it to your servers over SSH, and write and run your first playbook that installs and configures Nginx. Everything here works on Ubuntu 24.04 and 22.04 and uses only real, current commands.

Prerequisites

  • One Ubuntu 24.04 or 22.04 machine that acts as the control node — this can be your laptop (with WSL2), a VM, or a small VPS. Ansible runs here.
  • At least one Ubuntu server to manage (the managed node), reachable over SSH. A second VPS or a local VM is ideal for practice.
  • A non-root user with sudo privileges on the managed node.
  • The managed node's IP address or hostname, plus its SSH username.

Step 1 — Install Ansible on the control node

Ubuntu's universe repository ships Ansible, so on 24.04 and 22.04 a plain apt install gives you a current, stable release (ansible-core 2.16 on Ubuntu 24.04). Update the package lists first, then install:

sudo apt update
sudo apt install ansible -y

The first command refreshes the list of available packages so apt sees the latest versions. The second installs Ansible and its dependencies, including the Python runtime it uses locally.

If you want the very latest community release instead of the distro version, add the official Ansible PPA and install from there:

sudo apt install software-properties-common -y
sudo add-apt-repository --yes ppa:ansible/ansible
sudo apt update
sudo apt install ansible -y

add-apt-repository registers the Ansible team's personal package archive; after that, apt install ansible pulls the newest stable build rather than the one frozen at Ubuntu release time. For this tutorial the default Ubuntu package is perfectly fine.

Verify the installation:

ansible --version

You should see the ansible-core version, the config file location, and the Python version in use. If the command is found, the control node is ready.

Step 2 — Set up SSH key authentication to the managed node

Ansible logs in to managed nodes over SSH, and typing a password for every task defeats the purpose of automation. Generate an SSH key pair on the control node (skip this if you already have one you want to reuse):

ssh-keygen -t ed25519 -C "ansible-control" -f ~/.ssh/ansible_id -N ""

This creates an Ed25519 key pair: -t ed25519 selects the fast, modern key type, -C adds a label so you recognize the key later, -f sets the file name, and -N "" creates it without a passphrase. A passphrase-less key is convenient for automation, but only use one for a key dedicated to Ansible — never reuse your personal login key this way on production machines without considering the risk.

Copy the public key to the managed node. Replace deploy with your username and the IP with your server's:

ssh-copy-id -i ~/.ssh/ansible_id deploy@203.0.113.10

ssh-copy-id appends the public key to ~/.ssh/authorized_keys on the server. Confirm passwordless login works:

ssh -i ~/.ssh/ansible_id deploy@203.0.113.10 "echo OK"

If the server prints OK without asking for a password, Ansible can reach it unattended. Repeat the ssh-copy-id step for every server you plan to manage.

Step 3 — Create your inventory and Ansible config

Ansible needs to know which servers to talk to. That list lives in an inventory file. Create a project directory and an inventory.ini inside it:

mkdir -p ~/ansible && cd ~/ansible
cat > inventory.ini << 'EOF'
[webservers]
web1 ansible_host=203.0.113.10 ansible_user=deploy
web2 ansible_host=203.0.113.11 ansible_user=deploy
EOF

The [webservers] header defines a group; each line under it is a host. ansible_host is the real address to connect to, while ansible_user is the SSH login. If you only have one server, one line is enough. Grouping matters later: playbooks target groups, so adding a third web server is a one-line change.

Next, create an ansible.cfg in the same directory so you do not have to repeat options on every command:

cat > ansible.cfg << 'EOF'
[defaults]
inventory = ./inventory.ini
remote_user = deploy
private_key_file = ~/.ssh/ansible_id
retry_files_enabled = False
EOF

inventory points at the file you just wrote, remote_user sets the default SSH user, private_key_file tells Ansible which key to use, and retry_files_enabled = False stops Ansible from littering .retry files when a play fails. Ansible reads ansible.cfg from the current directory automatically, so always run your commands from ~/ansible.

First run tip: on a brand-new server, SSH will ask you to confirm the host key, which breaks unattended runs. Pre-accept it safely with ssh-keyscan -t ed25519 203.0.113.10 >> ~/.ssh/known_hosts for each server. Only disable host-key checking as a last resort, since it removes protection against man-in-the-middle attacks.

Step 4 — Test connectivity with an ad-hoc command

Before writing a playbook, prove Ansible can reach your servers. The ping module does not send ICMP packets — it logs in over SSH and runs a tiny Python snippet, which also confirms Python is present on the managed node:

ansible all -m ping

all is a built-in pattern matching every host in the inventory, and -m ping selects the module to run. A healthy response looks like this:

web1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

changed: false means the module made no changes — exactly what you want from a connectivity test. Try one more ad-hoc command that actually does something useful, updating the apt cache with elevated privileges:

ansible webservers -m ansible.builtin.apt -a "update_cache=yes cache_valid_time=3600" --become

-a passes arguments to the module, and --become enables privilege escalation (sudo). If this returns SUCCESS with changed: true, your control node can fully administer the servers.

Step 5 — Write your first playbook

A playbook is a YAML file describing the desired state of your servers. Create site.yml in ~/ansible:

cat > site.yml << 'EOF'
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Deploy a homepage
      ansible.builtin.copy:
        content: "<h1>Deployed by Ansible</h1>"
        dest: /var/www/html/index.html
        mode: "0644"
      notify: Restart Nginx

    - name: Make sure Nginx is running and enabled
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart Nginx
      ansible.builtin.service:
        name: nginx
        state: restarted
EOF

Reading it top to bottom: the play targets the webservers group with become: true (run everything as root via sudo). The four tasks refresh the apt cache, install Nginx, write a simple homepage, and ensure the service is running and starts on boot. The notify line triggers the handler at the end — handlers run only once per play, and only when a notifying task actually changed something, so Nginx restarts only when the homepage file changes, not on every run.

That change-only behavior is called idempotency: running the playbook twice produces the same result as running it once. It is the core reason Ansible is safe to run on a schedule.

Before running it for real, do a dry run to see what would change:

ansible-playbook site.yml --check --diff

--check runs in check mode (no changes made) and --diff shows the exact file differences. Now run it for real:

ansible-playbook site.yml

Ansible executes the tasks host by host. At the end you get a recap like ok=5 changed=4 failed=0 per host. Run the exact same command again: you should now see changed=0 everywhere — proof the playbook is idempotent and your servers are in the desired state.

Step 6 — Make it reusable with variables

Hard-coding values works for a first playbook, but real setups vary per environment. Ansible loads variables from group_vars/<group>.yml automatically. Create one for the webservers group:

mkdir -p group_vars
cat > group_vars/webservers.yml << 'EOF'
site_title: "Acme Corp — powered by Ansible"
admin_email: "ops@example.com"
EOF

Reference them in the playbook with Jinja2 {{ }} syntax. Update the homepage task:

    - name: Deploy a homepage
      ansible.builtin.copy:
        content: "<h1>{{ site_title }}</h1><p>Contact: {{ admin_email }}</p>"
        dest: /var/www/html/index.html
        mode: "0644"
      notify: Restart Nginx

Run ansible-playbook site.yml again and Ansible renders the template with your variables. When you later add a staging group, you create group_vars/staging.yml with different values and the same playbook deploys both environments without edits.

Verify it worked

Confirm Nginx is actually serving your Ansible-deployed page. From the control node, ask Ansible to check the service state:

ansible webservers -m ansible.builtin.command -a "systemctl is-active nginx"

Every host should return active. Then fetch the homepage directly:

curl http://203.0.113.10

You should see your site_title HTML. Finally, verify idempotency one more time — a second full run changing nothing is the hallmark of a correct playbook:

ansible-playbook site.yml | tail -8

The recap should show changed=0 and failed=0 for every host.

Common issues and fixes

1. UNREACHABLE — "Host key verification failed"

SSH refuses to connect because the server's host key is unknown or changed. Fix it properly by adding the key to known hosts rather than disabling the check:

ssh-keyscan -t ed25519 203.0.113.10 >> ~/.ssh/known_hosts

If the key genuinely changed (for example, the server was rebuilt), remove the stale entry first with ssh-keygen -R 203.0.113.10 and re-run keyscan.

2. "Failed to connect to the host via ssh: Permission denied (publickey)"

Ansible is offering the wrong key or none at all. First confirm the key works manually with ssh -i ~/.ssh/ansible_id deploy@HOST. If the manual login works but Ansible fails, your ansible.cfg is not being read — make sure you run commands from the directory containing it, or export ANSIBLE_CONFIG=~/ansible/ansible.cfg.

3. "Missing sudo password" when tasks use become

Your user needs a password for sudo, but Ansible was not given one. Either allow passwordless sudo for the Ansible user on managed nodes (add deploy ALL=(ALL) NOPASSWD:ALL via visudo), or pass --ask-become-pass to ansible-playbook so it prompts once at startup.

4. Playbook fails with a Python error on the managed node

Minimal Ubuntu images sometimes lack Python3. Bootstrap it with a raw command, which does not need Python on the remote side:

ansible webservers -m ansible.builtin.raw -a "apt update && apt install -y python3" --become

After that, normal modules work. If a server has Python at an unusual path, set ansible_python_interpreter=/usr/bin/python3 for that host in the inventory.

5. YAML syntax errors — "did not find expected key"

Nine times out of ten this is indentation. Ansible playbooks use two-space indentation, and tabs are never valid YAML. Validate before running with python3 -c "import yaml,sys; yaml.safe_load(open('site.yml'))" (install python3-yaml if needed) or use ansible-playbook site.yml --syntax-check.


Conclusion

You now have a working Ansible setup: a control node with Ansible installed, key-based SSH access to your servers, an inventory describing them, and an idempotent playbook that installs and configures Nginx with variables and handlers. From here, the natural next steps are roles (reusable playbook packages from Ansible Galaxy), ansible-vault for encrypting secrets, and scheduling playbooks to run regularly so configuration drift fixes itself.

If server automation feels like a lot to set up alongside your day job, XpertBees designs and maintains Ansible-based infrastructure for teams of every size — from a first playbook to fully automated multi-server fleets. Get in touch and we will take the repetitive work off your plate.