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 ansible package on older distributions may be quite outdated. Using pip install ansible gives you the latest version regardless of distribution. On RHEL-family systems, you may need ansible-core rather than ansible from 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_user tells Ansible which SSH user to connect as.
  • Two built-in groups always exist: all (every host) and ungrouped (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 command and shell modules? 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:

  1. state: installed is wrong. The correct value for apt module is state: present (or latest, absent). Ansible will throw an error.

  2. Handler name mismatch. The task notifies restart nginx (lowercase 'r') but the handler is named Restart nginx (uppercase 'R'). Handler names are case-sensitive. The handler will never fire.

  3. Absolute path in src of the copy module. When src is 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 like files/nginx.conf.

  4. No update_cache: yes on the apt task. 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:

  1. Creates a user called appuser with a home directory
  2. Creates the directory /opt/myapp owned by appuser
  3. Copies a simple script to /opt/myapp/health.sh that echoes "OK"
  4. 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.