Ansible: Agentless Automation
Why This Matters
You just got promoted. Congratulations -- you now manage 30 servers instead of three. Your boss asks you to deploy a security patch to all of them before end of business. You could SSH into each one, run the same commands 30 times, and pray you do not make a typo on server 17. Or you could write five lines of YAML and let Ansible do it in parallel across all 30 machines in under a minute.
Ansible is the most approachable Infrastructure as Code tool in the Linux ecosystem. It requires no agent software on the managed machines -- just SSH access and Python (which is already on virtually every Linux box). You describe tasks in plain YAML, and Ansible handles the rest: connecting, executing, reporting, and ensuring idempotency.
If you manage more than one server, Ansible will change your life. If you manage hundreds, it is indispensable.
Try This Right Now
If you have Ansible installed (we will install it shortly if you do not), try this one-liner:
$ ansible localhost -m ping
Expected output:
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}
That just used Ansible's ping module to verify it can connect to and execute on localhost. No SSH was needed for localhost, but the same command works against remote machines.
If Ansible is not installed yet, read on -- installation is two commands away.
Installing Ansible
Ansible runs on the control node -- your workstation or a management server. The managed nodes (the servers you are configuring) need nothing installed beyond SSH and Python.
On Debian/Ubuntu
$ sudo apt update
$ sudo apt install -y ansible
On Fedora
$ sudo dnf install -y ansible
On RHEL/AlmaLinux/Rocky (with EPEL)
$ sudo dnf install -y epel-release
$ sudo dnf install -y ansible-core
On any system with pip
$ python3 -m pip install --user ansible
Verify the installation
$ ansible --version
ansible [core 2.16.3]
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
python version = 3.11.6
jinja version = 3.1.2
libyaml = True
Distro Note: The
ansiblepackage on older distributions may be quite outdated. Usingpip install ansiblegives you the latest version regardless of distribution. On RHEL-family systems, you may needansible-corerather thanansiblefrom the base repos.
The Inventory: Telling Ansible What to Manage
Before Ansible can do anything, it needs to know which machines to manage. This is the inventory.
Static Inventory
The simplest inventory is a plain text file listing hostnames or IP addresses:
$ mkdir -p ~/ansible-lab
$ cat > ~/ansible-lab/inventory << 'EOF'
[webservers]
web1.example.com
web2.example.com
192.168.1.50
[databases]
db1.example.com
db2.example.com
[all:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
EOF
Key concepts:
- Groups are defined in
[brackets]. A host can belong to multiple groups. [all:vars]sets variables for all hosts.ansible_usertells Ansible which SSH user to connect as.- Two built-in groups always exist:
all(every host) andungrouped(hosts not in any explicit group).
INI vs. YAML Inventory Format
The same inventory in YAML:
# ~/ansible-lab/inventory.yml
all:
vars:
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3
children:
webservers:
hosts:
web1.example.com:
web2.example.com:
192.168.1.50:
databases:
hosts:
db1.example.com:
db2.example.com:
Testing Your Inventory
$ ansible -i ~/ansible-lab/inventory all --list-hosts
hosts (5):
web1.example.com
web2.example.com
192.168.1.50
db1.example.com
db2.example.com
$ ansible -i ~/ansible-lab/inventory webservers --list-hosts
hosts (3):
web1.example.com
web2.example.com
192.168.1.50
Dynamic Inventory
For cloud environments where servers come and go, static files become stale. Dynamic inventory scripts or plugins query your cloud provider's API to get the current list of machines:
# Example: using an AWS EC2 dynamic inventory plugin
# ansible-inventory -i aws_ec2.yml --list
Dynamic inventory is configured through YAML plugin files. Cloud-specific plugins exist for AWS, GCP, Azure, DigitalOcean, and many others.
Think About It: You have 50 servers across 3 environments (dev, staging, production). How would you organize your inventory files so you can target just production web servers, or all databases across all environments?
Ad-Hoc Commands: Quick One-Off Tasks
Before writing playbooks, let us use Ansible's ad-hoc mode for quick tasks. Ad-hoc commands use the ansible command (not ansible-playbook).
For these examples, we will use localhost so you can follow along without remote servers:
# Ping localhost
$ ansible localhost -m ping
localhost | SUCCESS => {
"changed": false,
"ping": "pong"
}
# Gather system facts
$ ansible localhost -m setup -a "filter=ansible_distribution*"
localhost | SUCCESS => {
"ansible_facts": {
"ansible_distribution": "Ubuntu",
"ansible_distribution_file_variety": "Debian",
"ansible_distribution_major_version": "22",
"ansible_distribution_release": "jammy",
"ansible_distribution_version": "22.04"
},
"changed": false
}
# Run a shell command
$ ansible localhost -m shell -a "uptime"
localhost | CHANGED | rc=0 >>
14:32:07 up 5 days, 3:21, 2 users, load average: 0.15, 0.20, 0.18
# Check disk space
$ ansible localhost -m shell -a "df -h /"
localhost | CHANGED | rc=0 >>
Filesystem Size Used Avail Use% Mounted on
/dev/sda1 50G 12G 36G 25% /
Ad-Hoc with Remote Servers
If you have SSH access to remote machines, the syntax is the same but you specify the inventory:
# Ping all web servers
$ ansible -i inventory webservers -m ping
# Install a package on all databases
$ ansible -i inventory databases -m apt -a "name=postgresql state=present" --become
# Restart a service on all web servers
$ ansible -i inventory webservers -m service -a "name=nginx state=restarted" --become
The -m flag specifies the module, -a passes arguments, and --become runs with sudo.
Playbook Anatomy: The Heart of Ansible
Playbooks are YAML files that define a series of tasks to execute on a group of hosts. Here is the structure:
# ~/ansible-lab/first-playbook.yml
---
- name: Configure web servers # Play name (describes the goal)
hosts: webservers # Target group from inventory
become: yes # Run tasks with sudo
vars: # Variables for this play
http_port: 80
doc_root: /var/www/html
tasks: # List of tasks to execute
- name: Install nginx # Task name (describes the action)
apt: # Module to use
name: nginx # Module arguments
state: present
update_cache: yes
- name: Deploy index page
copy:
content: "<h1>Hello from {{ inventory_hostname }}</h1>"
dest: "{{ doc_root }}/index.html"
owner: www-data
group: www-data
mode: '0644'
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
handlers: # Special tasks triggered by notify
- name: Restart nginx
service:
name: nginx
state: restarted
┌──────────────────────────────────────────────────────────────┐
│ PLAYBOOK STRUCTURE │
│ │
│ Playbook │
│ └── Play 1: "Configure web servers" │
│ ├── hosts: webservers │
│ ├── become: yes │
│ ├── vars: │
│ │ └── http_port: 80 │
│ ├── tasks: │
│ │ ├── Task 1: Install nginx │
│ │ ├── Task 2: Deploy index page │
│ │ └── Task 3: Ensure nginx is running │
│ └── handlers: │
│ └── Handler 1: Restart nginx │
│ └── Play 2: "Configure databases" │
│ └── ... │
│ │
│ A playbook can contain multiple plays. │
│ Each play targets a group of hosts. │
│ Each play has a list of tasks. │
│ Tasks are executed in order, one at a time. │
│ │
└──────────────────────────────────────────────────────────────┘
Handlers
Handlers are special tasks that only run when notified by another task. They are typically used to restart services after a config change:
tasks:
- name: Update nginx config
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx # Only triggers if the file changed
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
If the template has not changed, the handler does not fire. This prevents unnecessary restarts.
Variables
Variables make playbooks reusable:
vars:
app_user: deploy
app_dir: /opt/myapp
packages:
- git
- python3
- python3-pip
tasks:
- name: Create application user
user:
name: "{{ app_user }}"
shell: /bin/bash
- name: Install required packages
apt:
name: "{{ packages }}"
state: present
Variables can come from many sources: playbook vars, inventory, external files, command line (-e), or gathered facts.
Hands-On: Your First Real Playbook
Let us write a playbook that works on localhost so you can run it right now.
Step 1: Create the playbook:
$ cat > ~/ansible-lab/local-setup.yml << 'PLAYBOOK'
---
- name: Configure local development environment
hosts: localhost
connection: local
become: yes
vars:
dev_packages:
- git
- curl
- wget
- htop
- tree
- jq
motd_message: "This machine is managed by Ansible. Manual changes will be overwritten."
tasks:
- name: Update package cache
apt:
update_cache: yes
cache_valid_time: 3600
when: ansible_os_family == "Debian"
- name: Install development packages (Debian)
apt:
name: "{{ dev_packages }}"
state: present
when: ansible_os_family == "Debian"
- name: Install development packages (RedHat)
dnf:
name: "{{ dev_packages }}"
state: present
when: ansible_os_family == "RedHat"
- name: Set message of the day
copy:
content: "{{ motd_message }}\n"
dest: /etc/motd
owner: root
group: root
mode: '0644'
- name: Ensure important directories exist
file:
path: "{{ item }}"
state: directory
mode: '0755'
loop:
- /opt/scripts
- /opt/backups
- /opt/logs
- name: Display completion message
debug:
msg: "Local environment configured successfully!"
PLAYBOOK
Step 2: Run it in check mode first (dry run):
$ ansible-playbook ~/ansible-lab/local-setup.yml --check
Check mode shows what would change without actually changing anything.
Step 3: Run it for real:
$ ansible-playbook ~/ansible-lab/local-setup.yml
Expected output:
PLAY [Configure local development environment] ********************************
TASK [Gathering Facts] ********************************************************
ok: [localhost]
TASK [Update package cache] ***************************************************
changed: [localhost]
TASK [Install development packages (Debian)] **********************************
changed: [localhost]
TASK [Install development packages (RedHat)] **********************************
skipping: [localhost]
TASK [Set message of the day] *************************************************
changed: [localhost]
TASK [Ensure important directories exist] *************************************
changed: [localhost] => (item=/opt/scripts)
changed: [localhost] => (item=/opt/backups)
changed: [localhost] => (item=/opt/logs)
TASK [Display completion message] *********************************************
ok: [localhost] => {
"msg": "Local environment configured successfully!"
}
PLAY RECAP ********************************************************************
localhost : ok=6 changed=4 unreachable=0 failed=0 skipped=1
Step 4: Run it again -- observe idempotency:
$ ansible-playbook ~/ansible-lab/local-setup.yml
This time, most tasks report ok instead of changed. Ansible checked and found the system already matched the desired state.
Essential Ansible Modules
Modules are the building blocks of Ansible tasks. Here are the ones you will use most.
Package Management
# Debian/Ubuntu
- name: Install packages
apt:
name:
- nginx
- postgresql
state: present # present, absent, latest
update_cache: yes
# RHEL/Fedora
- name: Install packages
dnf:
name:
- httpd
- mariadb-server
state: present
File Management
# Copy a file
- name: Copy config file
copy:
src: files/app.conf # Local file on control node
dest: /etc/app/app.conf # Destination on managed node
owner: root
group: root
mode: '0644'
backup: yes # Keep backup of original
# Create a file from a Jinja2 template
- name: Deploy nginx config from template
template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: '0644'
notify: Restart nginx
# Manage files and directories
- name: Create directory
file:
path: /opt/myapp
state: directory
owner: deploy
group: deploy
mode: '0755'
- name: Create a symlink
file:
src: /opt/myapp/current
dest: /var/www/app
state: link
Service Management
- name: Ensure nginx is running and enabled
service:
name: nginx
state: started # started, stopped, restarted, reloaded
enabled: yes # Start on boot
User and Group Management
- name: Create application user
user:
name: deploy
shell: /bin/bash
groups: sudo,docker
append: yes # Add to groups without removing from others
create_home: yes
- name: Add SSH key for deploy user
authorized_key:
user: deploy
key: "{{ lookup('file', 'files/deploy_id_rsa.pub') }}"
state: present
Command Execution
# Run a command (only if needed)
- name: Initialize the database
command: /opt/app/bin/init-db
args:
creates: /opt/app/data/initialized # Skip if this file exists
# Run a shell command (supports pipes, redirects)
- name: Check disk usage
shell: df -h / | tail -1
register: disk_usage
- name: Show disk usage
debug:
var: disk_usage.stdout
Think About It: Why does Ansible have both
commandandshellmodules? When would you choose one over the other?
Roles: Organizing Playbooks at Scale
As your Ansible codebase grows, stuffing everything into one playbook becomes unmanageable. Roles provide a standard way to organize tasks, files, templates, and variables into reusable units.
Role Directory Structure
roles/
└── webserver/
├── tasks/
│ └── main.yml # Main list of tasks
├── handlers/
│ └── main.yml # Handlers
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates
├── files/
│ └── index.html # Static files
├── vars/
│ └── main.yml # Role variables
├── defaults/
│ └── main.yml # Default variable values (lowest priority)
└── meta/
└── main.yml # Role metadata (dependencies, etc.)
Creating a Role
$ mkdir -p ~/ansible-lab/roles
$ ansible-galaxy init ~/ansible-lab/roles/webserver
This creates the full directory structure. Now populate it:
$ cat > ~/ansible-lab/roles/webserver/tasks/main.yml << 'EOF'
---
- name: Install nginx
apt:
name: nginx
state: present
update_cache: yes
when: ansible_os_family == "Debian"
- name: Deploy nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Restart nginx
- name: Deploy website
copy:
src: index.html
dest: /var/www/html/index.html
owner: www-data
group: www-data
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
EOF
$ cat > ~/ansible-lab/roles/webserver/handlers/main.yml << 'EOF'
---
- name: Restart nginx
service:
name: nginx
state: restarted
EOF
$ cat > ~/ansible-lab/roles/webserver/defaults/main.yml << 'EOF'
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
server_name: localhost
EOF
Using a Role in a Playbook
# ~/ansible-lab/site.yml
---
- name: Configure web servers
hosts: webservers
become: yes
roles:
- webserver
That one line -- - webserver -- pulls in all tasks, handlers, templates, files, and variables from the role. Clean, reusable, and readable.
Ansible Galaxy: Community Roles
Ansible Galaxy is a repository of community-contributed roles. Instead of writing everything from scratch, you can install pre-built roles:
# Search for roles
$ ansible-galaxy search nginx
# Install a role
$ ansible-galaxy install geerlingguy.nginx
# List installed roles
$ ansible-galaxy list
You can also define role dependencies in a requirements.yml:
# requirements.yml
---
roles:
- name: geerlingguy.nginx
version: "3.1.0"
- name: geerlingguy.postgresql
version: "3.4.0"
$ ansible-galaxy install -r requirements.yml
Ansible Vault: Managing Secrets
Never put passwords or API keys in plain text. Ansible Vault encrypts sensitive data.
Encrypting a Variable File
$ cat > ~/ansible-lab/secrets.yml << 'EOF'
---
db_password: "SuperSecret123!"
api_key: "ak_live_abc123def456"
EOF
$ ansible-vault encrypt ~/ansible-lab/secrets.yml
New Vault password:
Confirm New Vault password:
Encryption successful
The file is now AES-256 encrypted:
$ cat ~/ansible-lab/secrets.yml
$ANSIBLE_VAULT;1.1;AES256
36383836656233613766623335383666316137663262383633303732356134343130613636326230
...
Using Encrypted Files in Playbooks
---
- name: Deploy application
hosts: appservers
become: yes
vars_files:
- secrets.yml
tasks:
- name: Configure database connection
template:
src: db_config.j2
dest: /opt/app/config/database.yml
mode: '0600'
# Run with vault password prompt
$ ansible-playbook deploy.yml --ask-vault-pass
# Or use a password file
$ ansible-playbook deploy.yml --vault-password-file ~/.vault_pass
Safety Warning: Never commit your vault password file to Git. Add it to
.gitignore. The whole point of vault is to keep secrets safe -- do not undermine it by exposing the master password.
Encrypting a Single Variable
You can also encrypt individual variables instead of whole files:
$ ansible-vault encrypt_string 'SuperSecret123!' --name 'db_password'
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
61326634356234633166...
Paste this directly into your variable files.
Practical Playbook: Configure a Web Server
Here is a complete, production-style playbook:
# ~/ansible-lab/webserver-playbook.yml
---
- name: Deploy and configure web server
hosts: localhost
connection: local
become: yes
vars:
server_name: myapp.example.com
doc_root: /var/www/myapp
nginx_worker_processes: auto
nginx_worker_connections: 1024
tasks:
- name: Install required packages
apt:
name:
- nginx
- ufw
state: present
update_cache: yes
when: ansible_os_family == "Debian"
- name: Create document root
file:
path: "{{ doc_root }}"
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Deploy application files
copy:
content: |
<!DOCTYPE html>
<html>
<head><title>{{ server_name }}</title></head>
<body>
<h1>Welcome to {{ server_name }}</h1>
<p>Deployed by Ansible on {{ ansible_date_time.iso8601 }}</p>
<p>Running on {{ ansible_distribution }} {{ ansible_distribution_version }}</p>
</body>
</html>
dest: "{{ doc_root }}/index.html"
owner: www-data
group: www-data
mode: '0644'
- name: Deploy nginx site configuration
copy:
content: |
server {
listen 80;
server_name {{ server_name }};
root {{ doc_root }};
index index.html;
access_log /var/log/nginx/{{ server_name }}_access.log;
error_log /var/log/nginx/{{ server_name }}_error.log;
location / {
try_files $uri $uri/ =404;
}
}
dest: /etc/nginx/sites-available/{{ server_name }}
owner: root
group: root
mode: '0644'
notify: Reload nginx
- name: Enable the site
file:
src: /etc/nginx/sites-available/{{ server_name }}
dest: /etc/nginx/sites-enabled/{{ server_name }}
state: link
notify: Reload nginx
- name: Remove default site
file:
path: /etc/nginx/sites-enabled/default
state: absent
notify: Reload nginx
- name: Test nginx configuration
command: nginx -t
changed_when: false
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
handlers:
- name: Reload nginx
service:
name: nginx
state: reloaded
Run it:
$ ansible-playbook ~/ansible-lab/webserver-playbook.yml
Debug This
Your colleague wrote this playbook and it fails. Can you spot the issues?
---
- name: Setup app server
hosts: appservers
become: true
tasks:
- name: Install packages
apt:
name: [nginx, python3]
state: installed
- name: Copy config
copy:
src: /home/admin/nginx.conf
dest: /etc/nginx/nginx.conf
notify: restart nginx
handlers:
- name: Restart nginx
service:
name: nginx
state: restarted
Problems:
-
state: installedis wrong. The correct value for apt module isstate: present(orlatest,absent). Ansible will throw an error. -
Handler name mismatch. The task notifies
restart nginx(lowercase 'r') but the handler is namedRestart nginx(uppercase 'R'). Handler names are case-sensitive. The handler will never fire. -
Absolute path in
srcof thecopymodule. Whensrcis an absolute path, Ansible copies from that exact path on the control node. This works, but if you meant to use a file relative to the playbook, use a relative path likefiles/nginx.conf. -
No
update_cache: yeson theapttask. On a fresh server, the apt cache may be empty and package installation will fail.
What Just Happened?
┌──────────────────────────────────────────────────────────────┐
│ CHAPTER 68 RECAP │
│──────────────────────────────────────────────────────────────│
│ │
│ Ansible = agentless automation over SSH using YAML. │
│ │
│ Key components: │
│ • Inventory: defines which machines to manage │
│ • Ad-hoc commands: quick one-off tasks (ansible -m) │
│ • Playbooks: YAML files defining plays, tasks, handlers │
│ • Modules: apt, dnf, copy, template, service, user, file │
│ • Roles: reusable, structured collections of tasks │
│ • Galaxy: community role repository │
│ • Vault: encrypted secrets management │
│ │
│ Workflow: │
│ 1. Define inventory (who to manage) │
│ 2. Write playbook (what to do) │
│ 3. Run with --check first (dry run) │
│ 4. Apply with ansible-playbook │
│ 5. Run again to verify idempotency │
│ │
│ Ansible checks before acting -- it only makes changes │
│ when the current state differs from the desired state. │
│ │
└──────────────────────────────────────────────────────────────┘
Try This
Exercise 1: Inventory Practice
Create an inventory file with three groups: webservers, databases, and monitoring. Add localhost to all three groups. Then run:
$ ansible -i your_inventory webservers --list-hosts
$ ansible -i your_inventory databases --list-hosts
$ ansible -i your_inventory all --list-hosts
Verify that all shows localhost only once even though it is in multiple groups.
Exercise 2: Write a Playbook
Write a playbook that:
- Creates a user called
appuserwith a home directory - Creates the directory
/opt/myappowned byappuser - Copies a simple script to
/opt/myapp/health.shthat echoes "OK" - Makes the script executable
Run it on localhost and verify everything was created correctly.
Exercise 3: Explore Facts
Run ansible localhost -m setup and examine the output. Find:
- Your operating system name and version
- Total memory
- All network interfaces and their IP addresses
- The number of CPU cores
Exercise 4: Vault Practice
Create a file with a fake database password, encrypt it with ansible-vault, then write a playbook that uses the encrypted variable. Run the playbook with --ask-vault-pass.
Bonus Challenge
Create a role called hardening that:
- Disables root SSH login (modifies
/etc/ssh/sshd_config) - Sets a secure MOTD
- Installs and enables
fail2ban - Configures the firewall to allow only SSH (port 22) and HTTP (port 80)
Use the role in a playbook, and test with --check mode before applying.