Ansible for Linux Automation: Complete Beginner Guide

Tested on: Ubuntu 26.04 LTS · Debian 12 · Rocky Linux 10 — Ansible 2.16 controller targeting Ubuntu and Rocky Linux managed nodes — Last updated: June 2026
Ansible is the most widely deployed Linux automation tool in production environments — and for good reason. You write YAML playbooks describing the desired state of your servers, and Ansible enforces it: installing packages, managing services, deploying configs, creating users, hardening SSH. It's agentless, meaning no daemon runs on managed nodes — just SSH and Python 3, both present on virtually every Linux server by default.
Prerequisites
- A Linux control node (your workstation, a jump host, or a CI runner) — Ubuntu 26.04 or Debian 12 recommended
- SSH access to at least one managed node (a VM, cloud instance, or bare metal server)
- SSH key-based authentication already configured (password auth works but key-based is assumed here)
- Python 3.8+ on managed nodes (pre-installed on Ubuntu 24.04+, Debian 13+, Rocky 8+)
- sudo/root access on managed nodes for privileged tasks
How Ansible Works
Understanding the core concepts before writing a single line of YAML saves hours of confusion later.
- Control node: The machine where you install Ansible and run playbooks — your laptop or a dedicated automation server
- Managed nodes: The servers Ansible configures — no Ansible installation required, just SSH + Python 3
- Inventory: A list of managed nodes, their addresses, connection parameters, and logical groupings
- Playbook: A YAML file containing one or more plays; each play maps a group of hosts to a list of tasks
- Module: The unit of work — a self-contained script that Ansible ships to the managed node and executes (install a package, write a file, restart a service)
- Idempotency: Running the same playbook twice produces identical results — Ansible checks state before acting. If Nginx is already installed, the install task reports
ok, notchanged - Facts: Variables Ansible gathers automatically about each managed node — OS family, IP addresses, CPU count, memory, hostname
The execution flow: you run ansible-playbook on the control node → Ansible reads the inventory and playbook → connects to each managed node via SSH → uploads and executes Python module code → reports results back. Nothing persists on the managed node after execution.
Install Ansible
# Ubuntu/Debian — distribution package (may lag behind upstream):
sudo apt update && sudo apt install ansible -y
# Ubuntu/Debian — latest stable from official Ansible PPA:
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install ansible -y
# Fedora / Rocky Linux / RHEL 9:
sudo dnf install ansible -y
# Any distro — latest via pip (recommended for consistent versioning):
pip3 install --user ansible
# Verify installation:
ansible --versionExpected output:
ansible [core 2.16.6]
config file = /etc/ansible/ansible.cfg
configured module search path = ['/home/user/.ansible/plugins/modules']
ansible python module location = /usr/lib/python3/dist-packages/ansible
ansible collection location = /home/user/.ansible/collections
executable location = /usr/bin/ansible
python version = 3.12.3
jinja version = 3.1.2Inventory Files
The inventory tells Ansible which servers to manage and how to reach them. INI format is the most common; YAML format is better for complex nested structures.
# hosts.ini — INI format inventory
[webservers]
web1.example.com
web2.example.com ansible_port=2222
[databases]
db1.example.com ansible_user=dbadmin
[monitoring]
prometheus.example.com
# Group of groups:
[production:children]
webservers
databases
# Variables applied to all hosts:
[all:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/id_ed25519
ansible_python_interpreter=/usr/bin/python3# inventory.yml — YAML format (preferred for complex environments)
all:
vars:
ansible_user: ubuntu
ansible_ssh_private_key_file: ~/.ssh/id_ed25519
children:
webservers:
hosts:
web1:
ansible_host: 192.168.1.10
web2:
ansible_host: 192.168.1.11
ansible_port: 2222
databases:
hosts:
db1:
ansible_host: 192.168.1.20
ansible_user: dbadmin# Test connectivity to all hosts:
ansible -i hosts.ini all -m ping
# Expected output:
# web1.example.com | SUCCESS => {"changed": false, "ping": "pong"}
# web2.example.com | SUCCESS => {"changed": false, "ping": "pong"}
# List all hosts in inventory:
ansible -i hosts.ini all --list-hosts
# List hosts in a specific group:
ansible -i hosts.ini webservers --list-hostsStore your inventory in a project directory alongside your playbooks. For larger environments, use dynamic inventory — scripts or plugins that pull host lists from AWS, GCP, Proxmox, or a CMDB at runtime.
Ad-Hoc Commands
Ad-hoc commands run a single module against hosts without a playbook. Use them for quick checks, one-off tasks, and exploring module behavior before writing a playbook.
# Syntax: ansible [host-pattern] -i [inventory] -m [module] -a "[arguments]"
# Check uptime on all servers:
ansible all -i hosts.ini -m command -a "uptime"
# Check disk space on web servers:
ansible webservers -i hosts.ini -m command -a "df -h /"
# Install a package (--become = sudo):
ansible all -i hosts.ini -m package -a "name=htop state=present" --become
# Start and enable a service:
ansible webservers -i hosts.ini -m service -a "name=nginx state=started enabled=yes" --become
# Copy a file to all servers:
ansible all -i hosts.ini -m copy -a "src=/etc/hosts dest=/tmp/hosts_backup" --become
# Gather and filter facts:
ansible web1.example.com -i hosts.ini -m setup -a "filter=ansible_distribution*"
# Returns: ansible_distribution, ansible_distribution_version, ansible_distribution_release
# Reboot all servers (with confirmation prompt — use carefully):
ansible all -i hosts.ini -m reboot --becomeYour First Playbook
Playbooks are YAML files. The structure: a list of plays, each play targeting a group of hosts and containing a list of tasks. Each task calls a module with arguments.
# site.yml — Install, configure, and start Nginx
---
- name: Configure web servers
hosts: webservers
become: yes # run all tasks with sudo
tasks:
- name: Install Nginx
package:
name: nginx
state: present
- name: Ensure Nginx is started and enabled at boot
service:
name: nginx
state: started
enabled: yes
- name: Create web root directory
file:
path: /var/www/myapp
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Deploy index page
copy:
content: "Deployed by Ansible on {{ ansible_hostname }}
"
dest: /var/www/myapp/index.html
owner: www-data
mode: '0644'# Syntax check before running:
ansible-playbook -i hosts.ini site.yml --syntax-check
# Dry run — shows what would change without applying anything:
ansible-playbook -i hosts.ini site.yml --check
# Run the playbook:
ansible-playbook -i hosts.ini site.yml
# Verbose output — shows module arguments and return values:
ansible-playbook -i hosts.ini site.yml -v
# Very verbose — full SSH debug and module output:
ansible-playbook -i hosts.ini site.yml -vvv
# Run only tasks tagged 'config':
ansible-playbook -i hosts.ini site.yml --tags config
# Limit execution to a single host:
ansible-playbook -i hosts.ini site.yml --limit web1.example.comA successful run produces output like this:
PLAY [Configure web servers] ***************************************************
TASK [Gathering Facts] *********************************************************
ok: [web1.example.com]
ok: [web2.example.com]
TASK [Install Nginx] ***********************************************************
changed: [web1.example.com]
changed: [web2.example.com]
TASK [Ensure Nginx is started and enabled at boot] *****************************
ok: [web1.example.com]
ok: [web2.example.com]
PLAY RECAP *********************************************************************
web1.example.com : ok=4 changed=2 unreachable=0 failed=0
web2.example.com : ok=4 changed=2 unreachable=0 failed=0Variables and group_vars
Hardcoding values into playbooks makes them brittle and non-reusable. Variables let you parameterize everything — and Ansible has a well-defined precedence order for where variables come from.
# Inline vars in a playbook:
---
- name: Deploy application
hosts: webservers
become: yes
vars:
app_user: deploy
app_dir: /var/www/myapp
app_port: 8080
packages:
- nginx
- python3
- python3-pip
- git
tasks:
- name: Create app user
user:
name: "{{ app_user }}"
system: yes
shell: /bin/bash
home: "{{ app_dir }}"
- name: Install required packages
package:
name: "{{ item }}"
state: present
loop: "{{ packages }}"# group_vars/webservers.yml — auto-loaded for hosts in [webservers]:
nginx_port: 80
app_version: "2.1.0"
enable_ssl: true
domain: "myapp.example.com"# host_vars/web1.example.com.yml — auto-loaded for web1 only:
nginx_port: 8080 # overrides group var for this host only# Pass variables at runtime:
ansible-playbook -i hosts.ini site.yml -e "app_version=2.2.0"
# Pass a variables file:
ansible-playbook -i hosts.ini site.yml -e @release_vars.ymlVariable precedence (lowest to highest): role defaults → inventory group vars → inventory host vars → playbook vars → extra vars (-e). When in doubt, extra vars win.
Jinja2 Templates
The template module renders Jinja2 templates on the control node using Ansible variables and facts, then copies the result to managed nodes. This is how you generate configuration files dynamically — one template, deployed correctly on every server regardless of its specific IP, hostname, or OS.
# templates/nginx.conf.j2
server {
listen {{ nginx_port | default(80) }};
server_name {{ ansible_hostname }}.{{ domain }};
root {{ app_dir }}/public;
index index.html;
access_log /var/log/nginx/{{ ansible_hostname }}_access.log;
error_log /var/log/nginx/{{ ansible_hostname }}_error.log;
{% if enable_ssl | default(false) %}
listen 443 ssl;
ssl_certificate /etc/letsencrypt/live/{{ domain }}/fullchain.pem;
ssl_certificate_key /etc/letsencrypt/live/{{ domain }}/privkey.pem;
{% endif %}
location / {
proxy_pass http://127.0.0.1:{{ app_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}# In your playbook:
- name: Deploy Nginx virtual host config
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/myapp.conf
owner: root
group: root
mode: '0644'
validate: /usr/sbin/nginx -t -c %s
notify: Reload NginxThe validate parameter runs a command on the rendered file before placing it — if nginx -t fails, Ansible aborts the task and the broken config never lands on disk. Use this for any configuration file that has a syntax checker.
Handlers
Handlers are tasks that run only when notified, and only once per play regardless of how many tasks notify them. The canonical use case: restart or reload a service when its configuration changes, but not on every playbook run.
---
- name: Configure Nginx
hosts: webservers
become: yes
tasks:
- name: Deploy Nginx main config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload Nginx
- name: Deploy virtual host config
template:
src: vhost.conf.j2
dest: /etc/nginx/sites-available/myapp.conf
notify: Reload Nginx
- name: Enable virtual host
file:
src: /etc/nginx/sites-available/myapp.conf
dest: /etc/nginx/sites-enabled/myapp.conf
state: link
notify: Reload Nginx
handlers:
- name: Reload Nginx
service:
name: nginx
state: reloadedEven if all three tasks notify Reload Nginx, the handler executes exactly once at the end of the play. If no task changes, no notification fires and Nginx is never touched.
