Ansible is an open source automation tool that configures servers over plain SSH. It is agentless: nothing has to be installed on the managed servers except Python, which Ubuntu already includes. In this tutorial you will install Ansible on an Ubuntu 24.04 control node, give it SSH access to your servers, describe them in an inventory, run ad-hoc commands and write a first playbook that installs and starts Nginx.
Prerequisites
To follow this guide you need:
- One control node: an Ubuntu 24.04 machine where Ansible will run. It can be your workstation or a small CubePath VPS.
- One or more managed nodes: Ubuntu 24.04 servers that Ansible will configure. This guide uses two,
web1(web1_server_ip) andweb2(web2_server_ip). - A non-root user with
sudoprivileges on every managed node, calledyour_userin this guide. - SSH access (port 22) from the control node to every managed node.
Step 1 - Installing Ansible on the control node
Ubuntu's own repositories include Ansible, but it lags behind upstream. The Ansible project publishes current releases in the official ppa:ansible/ansible PPA, which supports Ubuntu 24.04.
On the control node, add the PPA and install the ansible package:
sudo apt update
sudo apt install software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible
The ansible package contains ansible-core (the engine and the built-in ansible.builtin modules) plus a curated set of collections such as community.general and ansible.posix.
Verify the installation:
ansible --version
ansible [core 2.18.6]
config file = None
configured module search path = ['/home/your_user/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /usr/lib/python3/dist-packages/ansible
executable location = /usr/bin/ansible
python version = 3.12.3 (main, ...) [GCC 13.2.0]
jinja version = 3.1.2
Your version numbers will differ. Nothing needs to be installed on the managed nodes.
Step 2 - Setting up SSH key authentication
Ansible connects to each server with SSH, so the control node needs passwordless key access to every managed node.
If you do not have a key on the control node yet, create one:
ssh-keygen -t ed25519 -C "ansible-control"
Press ENTER to accept the default path (~/.ssh/id_ed25519). A passphrase is recommended; if you set one, load the key into the agent before running Ansible with eval "$(ssh-agent)" and ssh-add.
Copy the public key to each managed node:
ssh-copy-id your_user@web1_server_ip
ssh-copy-id your_user@web2_server_ip
Test the connection. It should log in and print the hostname without asking for a password:
ssh your_user@web1_server_ip hostname
web1
This first connection also stores each server's host key in ~/.ssh/known_hosts. Ansible verifies host keys by default, and you should keep it that way.
Step 3 - Creating a project directory and an inventory
The inventory tells Ansible which servers exist, how to reach them and how they are grouped. Keep it inside a project directory together with its configuration and playbooks, so the whole project can live in Git.
Create the project directory:
mkdir ~/ansible-first-steps
cd ~/ansible-first-steps
Create the inventory file:
nano inventory.ini
Add your servers to a group called webservers:
[webservers]
web1 ansible_host=web1_server_ip
web2 ansible_host=web2_server_ip
[webservers:vars]
ansible_user=your_user
web1 and web2 are the names Ansible uses in its output, ansible_host is the address it connects to, and ansible_user sets the SSH user for every host in the group. Every host also belongs to the implicit group all.
List what Ansible sees in the inventory:
ansible-inventory -i inventory.ini --graph
@all:
|--@ungrouped:
|--@webservers:
| |--web1
| |--web2
Step 4 - Adding an ansible.cfg file
Instead of passing -i inventory.ini to every command, put the defaults in an ansible.cfg file. Ansible reads ansible.cfg from the current directory when you run commands from the project folder.
nano ansible.cfg
[defaults]
inventory = inventory.ini
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
inventorypoints to the inventory file.interpreter_python = auto_silentlets Ansible discover Python on each server without printing a warning.pipeliningreduces the number of SSH operations per task, which makes runs noticeably faster.
Confirm that Ansible picks up the file:
ansible --version | grep "config file"
config file = /home/your_user/ansible-first-steps/ansible.cfg
Step 5 - Running ad-hoc commands
Ad-hoc commands run a single module against a set of hosts, without writing a playbook. They are useful for quick checks and one-off tasks. The syntax is ansible <pattern> -m <module> -a "<arguments>".
Test connectivity with the ping module. It does not send ICMP packets: it logs in over SSH and checks that Python works.
ansible all -m ping
web1 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
web2 | SUCCESS => {
"ansible_facts": {
"discovered_interpreter_python": "/usr/bin/python3.12"
},
"changed": false,
"ping": "pong"
}
Run a shell command on every web server:
ansible webservers -m ansible.builtin.command -a "uptime"
web1 | CHANGED | rc=0 >>
10:14:02 up 3 days, 2:11, 1 user, load average: 0.00, 0.01, 0.00
web2 | CHANGED | rc=0 >>
10:14:02 up 3 days, 2:09, 1 user, load average: 0.02, 0.03, 0.00
Tasks that need root use --become (-b). Add -K (--ask-become-pass) so Ansible asks for your sudo password once:
ansible webservers -b -K -m ansible.builtin.apt -a "update_cache=yes"
Collect facts, the system information Ansible gathers from each host, and filter them:
ansible web1 -m ansible.builtin.setup -a "filter=ansible_distribution*"
web1 | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_parsed": true,
"ansible_distribution_file_path": "/etc/os-release",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "24",
"ansible_distribution_release": "noble",
"ansible_distribution_version": "24.04"
},
"changed": false
}
Step 6 - Writing your first playbook
Ad-hoc commands are imperative: you say what to run. A playbook is declarative: you describe the state you want, and Ansible only changes what is different. Playbooks are YAML files that can be reviewed, versioned and run again safely.
Create a playbook that installs Nginx, makes sure it is running, opens HTTP in UFW and deploys a simple home page:
nano webserver.yml
---
- name: Configure web servers
hosts: webservers
become: true
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
cache_valid_time: 3600
- name: Ensure Nginx is running and enabled at boot
ansible.builtin.systemd_service:
name: nginx
state: started
enabled: true
- name: Allow SSH through UFW
community.general.ufw:
rule: allow
name: OpenSSH
- name: Allow HTTP through UFW
community.general.ufw:
rule: allow
port: "80"
proto: tcp
- name: Enable UFW
community.general.ufw:
state: enabled
- name: Deploy the home page
ansible.builtin.copy:
dest: /var/www/html/index.html
content: "<h1>Deployed by Ansible on {{ inventory_hostname }}</h1>\n"
owner: www-data
group: www-data
mode: "0644"
A few things to note:
hosts: webserverstargets the group from the inventory, andbecome: trueruns every task with sudo.- Each task calls one module with its fully qualified name (
ansible.builtin.apt), which avoids ambiguity between collections. cache_valid_time: 3600only refreshes the apt cache if it is older than an hour.- The OpenSSH rule comes before
state: enabled, so enabling the firewall never locks you out. {{ inventory_hostname }}is a Jinja2 variable that holds the host's name from the inventory.
Check the syntax before running it:
ansible-playbook webserver.yml --syntax-check
playbook: webserver.yml
Step 7 - Running the playbook
Start with a dry run. --check reports what would change without changing anything, and --diff shows file differences:
ansible-playbook webserver.yml -K --check --diff
In check mode, some later tasks can report errors because earlier tasks did not actually run (for example, the Nginx service does not exist yet). That is expected on a first run.
Now apply the playbook:
ansible-playbook webserver.yml -K
Enter your sudo password at the BECOME password: prompt. The run ends with a recap per host:
PLAY RECAP *********************************************************************
web1 : ok=7 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=7 changed=5 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
ok counts tasks that ran successfully (including the fact-gathering task), and changed counts those that modified something.
Verify the result from the control node:
curl http://web1_server_ip
<h1>Deployed by Ansible on web1</h1>
Step 8 - Confirming idempotency
Run the same playbook again:
ansible-playbook webserver.yml -K
PLAY RECAP *********************************************************************
web1 : ok=7 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=7 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
changed=0 means the servers already match the described state, so Ansible did nothing. This property, called idempotency, is what makes playbooks safe to run repeatedly, for example on a schedule or after adding a new server to the inventory. It is also why you should prefer dedicated modules (apt, copy, systemd_service) over command or shell, which always report changed because Ansible cannot tell what they did.
Troubleshooting
UNREACHABLE! ... Permission denied (publickey,password): the SSH key is not installed for the user Ansible uses. Check ansible_user in the inventory and test with ssh your_user@web1_server_ip. Run Ansible with -vvv to see the exact SSH command it executes.
Missing sudo password: the task uses become and your user needs a password for sudo. Add -K to the command.
Host key verification failed: the server's key is not in ~/.ssh/known_hosts, or it changed (for example, after reinstalling the server). Connect once with ssh to accept it, or remove the old entry with ssh-keygen -R web1_server_ip.
couldn't resolve module/action 'community.general.ufw': you installed only ansible-core. Install the collection with ansible-galaxy collection install community.general, or install the full ansible package as in Step 1.
Conclusion
You installed Ansible on a control node, set up SSH key access, described your servers in an inventory, ran ad-hoc commands and applied an idempotent playbook that configures Nginx and the firewall on several servers at once.
From here, put the project in Git, learn to organize larger setups with templates and handlers in practical playbooks, and use variables, loops and conditionals to make the same playbook work across different environments.
