ANSIBLE — AGENTLESS AUTOMATION OVER SSH Control Node Playbooks Inventory Roles ansible-playbook SSH Web Servers nginx, app deploy DB Servers mysql, backups Load Balancers haproxy, certs Playbook Execution Flow 1. Gather Facts (setup module) 2. Execute Tasks (apt, copy, service) 3. Notify Handlers (restart services) 4. Report Results (ok/changed/failed) Idempotent — safe to run multiple times Push-based automation: no agents needed on managed hosts

Logging into dozens of servers one by one to install packages, edit configuration files, and restart services is tedious and error-prone. A forgotten step on one server leads to configuration drift, and suddenly your “identical” servers behave differently. Ansible solves this by letting you define your entire server configuration in simple YAML files and apply it consistently across hundreds of machines with a single command. Because Ansible is agentless and communicates over SSH, there is nothing to install on your managed servers — if you can SSH into a host, Ansible can manage it.

What Is Ansible?

Ansible is an open-source automation tool created by Red Hat that manages server configuration, application deployment, and orchestration. Unlike tools that require a dedicated agent running on every managed server, Ansible uses SSH to connect to remote hosts and execute tasks.

Core characteristics:

  • Agentless — No software to install or maintain on managed nodes. Ansible uses existing SSH connections
  • Push-based — The control node pushes configurations to managed hosts on demand, rather than hosts polling a central server
  • Idempotent — Running a playbook multiple times produces the same result. Ansible checks the current state before making changes
  • YAML-based — Playbooks are written in human-readable YAML, making them accessible to anyone who can read a configuration file
  • Extensible — Thousands of built-in modules for managing packages, files, services, cloud resources, and more

How Ansible Works

Ansible follows a straightforward execution model:

  1. You write a playbook (YAML file) describing the desired state of your servers
  2. You define an inventory listing which hosts to manage
  3. You run ansible-playbook, and Ansible connects to each host over SSH
  4. Ansible transfers small programs called modules to the remote host
  5. Modules execute, enforce the desired state, and report back
  6. Ansible removes the modules and displays a summary

No daemon runs on the managed hosts. No database tracks state. Each playbook run is self-contained.

Prerequisites

Before you begin, ensure you have:

  • An Ubuntu 22.04 or 24.04 machine as the control node (where Ansible runs)
  • One or more target servers accessible via SSH
  • SSH key-based authentication configured between the control node and managed hosts
  • Python 3 installed on all managed nodes (pre-installed on most Linux distributions)
  • A user account with sudo privileges on the managed hosts

Tip: If you have not set up SSH key authentication yet, follow our SSH hardening guide to configure secure key-based access before proceeding.

Installing Ansible on Ubuntu

Using the system package manager

sudo apt update
sudo apt install ansible -y
sudo apt install python3-pip python3-venv -y
python3 -m venv ~/ansible-venv
source ~/ansible-venv/bin/activate
pip install ansible

Verify the installation

ansible --version

You should see output showing the Ansible version, configuration file path, and Python version:

ansible [core 2.16.x]
  config file = /etc/ansible/ansible.cfg
  configured module search path = ['/home/user/.ansible/plugins/modules']
  python version = 3.12.x

Inventory Files

The inventory tells Ansible which hosts to manage. You can write inventories in INI or YAML format.

INI format (simple)

# inventory.ini
[webservers]
web1.example.com
web2.example.com
192.168.1.50

[dbservers]
db1.example.com ansible_port=2222
db2.example.com

[loadbalancers]
lb1.example.com

[production:children]
webservers
dbservers
loadbalancers

[all:vars]
ansible_user=deployer
ansible_ssh_private_key_file=~/.ssh/id_ed25519

YAML format

# inventory.yml
all:
  vars:
    ansible_user: deployer
    ansible_ssh_private_key_file: ~/.ssh/id_ed25519
  children:
    webservers:
      hosts:
        web1.example.com:
        web2.example.com:
        192.168.1.50:
    dbservers:
      hosts:
        db1.example.com:
          ansible_port: 2222
        db2.example.com:
    loadbalancers:
      hosts:
        lb1.example.com:
    production:
      children:
        webservers:
        dbservers:
        loadbalancers:

Testing connectivity

Verify that Ansible can reach all hosts:

ansible all -m ping -i inventory.ini

Expected output for a successful connection:

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

Your First Playbook

A playbook is a YAML file containing one or more “plays.” Each play targets a group of hosts and defines tasks to execute.

Install and configure nginx

# site.yml
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Update apt cache
      apt:
        update_cache: true
        cache_valid_time: 3600

    - name: Install nginx
      apt:
        name: nginx
        state: present

    - name: Copy nginx configuration
      copy:
        src: files/nginx.conf
        dest: /etc/nginx/sites-available/default
        owner: root
        group: root
        mode: "0644"
      notify: Restart nginx

    - name: Enable nginx site
      file:
        src: /etc/nginx/sites-available/default
        dest: /etc/nginx/sites-enabled/default
        state: link
      notify: Restart nginx

    - name: Ensure nginx is running and enabled
      service:
        name: nginx
        state: started
        enabled: true

  handlers:
    - name: Restart nginx
      service:
        name: nginx
        state: restarted

Run the playbook:

ansible-playbook -i inventory.ini site.yml

Run with verbose output for debugging:

ansible-playbook -i inventory.ini site.yml -v

Perform a dry run to see what would change without applying:

ansible-playbook -i inventory.ini site.yml --check --diff

Ansible Modules

Modules are the units of work in Ansible. Each module handles a specific task. Here are the most commonly used modules:

Package management (apt)

- name: Install multiple packages
  apt:
    name:
      - nginx
      - curl
      - ufw
      - fail2ban
    state: present
    update_cache: true

- name: Remove a package
  apt:
    name: apache2
    state: absent
    purge: true

Service management (service)

- name: Start and enable a service
  service:
    name: nginx
    state: started
    enabled: true

- name: Restart a service
  service:
    name: mysql
    state: restarted

File management (copy, template, file)

- name: Copy a file to remote host
  copy:
    src: files/app.conf
    dest: /etc/app/app.conf
    owner: www-data
    group: www-data
    mode: "0644"

- name: Deploy a template with variables
  template:
    src: templates/vhost.conf.j2
    dest: /etc/nginx/sites-available/myapp.conf
    owner: root
    group: root
    mode: "0644"

- name: Create a directory
  file:
    path: /var/www/myapp
    state: directory
    owner: www-data
    group: www-data
    mode: "0755"

Command execution (command, shell)

- name: Run a command
  command: /usr/bin/myapp --init
  args:
    creates: /var/lib/myapp/initialized

- name: Run a shell command with pipes
  shell: cat /var/log/syslog | grep error | wc -l
  register: error_count
  changed_when: false

User management (user)

- name: Create a deploy user
  user:
    name: deployer
    groups: sudo,www-data
    shell: /bin/bash
    create_home: true
    state: present

- name: Add SSH authorized key
  authorized_key:
    user: deployer
    key: "{{ lookup('file', '~/.ssh/id_ed25519.pub') }}"
    state: present

Variables and Facts

Variables make your playbooks flexible and reusable. Ansible supports several variable sources.

Defining variables in playbooks

---
- name: Deploy application
  hosts: webservers
  become: true
  vars:
    app_name: myapp
    app_port: 8080
    app_user: www-data
    packages:
      - nginx
      - python3
      - python3-pip

  tasks:
    - name: Install required packages
      apt:
        name: "{{ packages }}"
        state: present

    - name: Create app directory
      file:
        path: "/var/www/{{ app_name }}"
        state: directory
        owner: "{{ app_user }}"
        mode: "0755"

Group variables and host variables

Create directories for group-specific and host-specific variables:

project/
  inventory.ini
  group_vars/
    all.yml
    webservers.yml
    dbservers.yml
  host_vars/
    web1.example.com.yml
    db1.example.com.yml
  playbook.yml
# group_vars/webservers.yml
http_port: 80
https_port: 443
document_root: /var/www/html
max_workers: 4
# host_vars/web1.example.com.yml
server_name: web1.example.com
ssl_certificate: /etc/ssl/certs/web1.pem

Gathering facts

Ansible automatically gathers system facts (OS, IP addresses, memory, disk) from managed hosts. Use them in templates and conditionals:

- name: Display system information
  debug:
    msg: >
      Hostname: {{ ansible_hostname }},
      OS: {{ ansible_distribution }} {{ ansible_distribution_version }},
      IP: {{ ansible_default_ipv4.address }},
      RAM: {{ ansible_memtotal_mb }} MB

- name: Install packages based on OS
  apt:
    name: nginx
    state: present
  when: ansible_os_family == "Debian"

- name: Install packages on Red Hat
  yum:
    name: nginx
    state: present
  when: ansible_os_family == "RedHat"

Handlers and Notifications

Handlers are special tasks that run only when notified by another task. They are triggered at the end of a play, after all regular tasks have completed. This prevents unnecessary service restarts.

---
- name: Configure web server
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      apt:
        name: nginx
        state: present

    - name: Deploy main nginx config
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/nginx.conf
      notify:
        - Validate nginx config
        - Restart nginx

    - name: Deploy virtual host
      template:
        src: templates/vhost.conf.j2
        dest: /etc/nginx/sites-available/{{ domain }}.conf
      notify:
        - Validate nginx config
        - Restart nginx

    - name: Enable virtual host
      file:
        src: /etc/nginx/sites-available/{{ domain }}.conf
        dest: /etc/nginx/sites-enabled/{{ domain }}.conf
        state: link
      notify:
        - Validate nginx config
        - Restart nginx

  handlers:
    - name: Validate nginx config
      command: nginx -t
      listen: "Validate nginx config"

    - name: Restart nginx
      service:
        name: nginx
        state: restarted
      listen: "Restart nginx"

Even though three tasks notify the handlers, Restart nginx runs only once at the end of the play.

Roles and Directory Structure

Roles organize your playbooks into reusable, shareable components. Each role encapsulates tasks, handlers, variables, templates, and files for a specific function.

Creating a role

ansible-galaxy init roles/webserver

This generates the following structure:

roles/
  webserver/
    tasks/
      main.yml
    handlers/
      main.yml
    templates/
    files/
    vars/
      main.yml
    defaults/
      main.yml
    meta/
      main.yml

Role tasks

# roles/webserver/tasks/main.yml
---
- name: Install nginx
  apt:
    name: nginx
    state: present
    update_cache: true

- name: Deploy nginx configuration
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
  notify: Restart nginx

- name: Deploy virtual host
  template:
    src: vhost.conf.j2
    dest: "/etc/nginx/sites-available/{{ domain }}.conf"
  notify: Restart nginx

- name: Enable virtual host
  file:
    src: "/etc/nginx/sites-available/{{ domain }}.conf"
    dest: "/etc/nginx/sites-enabled/{{ domain }}.conf"
    state: link
  notify: Restart nginx

- name: Remove default virtual host
  file:
    path: /etc/nginx/sites-enabled/default
    state: absent
  notify: Restart nginx

- name: Ensure nginx is running
  service:
    name: nginx
    state: started
    enabled: true

Role handlers

# roles/webserver/handlers/main.yml
---
- name: Restart nginx
  service:
    name: nginx
    state: restarted

Role defaults

# roles/webserver/defaults/main.yml
---
domain: example.com
http_port: 80
https_port: 443
document_root: /var/www/html
worker_processes: auto
worker_connections: 1024

Using roles in a playbook

# site.yml
---
- name: Configure infrastructure
  hosts: webservers
  become: true
  roles:
    - role: webserver
      vars:
        domain: knowledgexchange.xyz
    - role: security
    - role: monitoring

Practical Examples

LAMP stack deployment

# lamp.yml
---
- name: Deploy LAMP stack
  hosts: webservers
  become: true
  vars:
    mysql_root_password: "{{ vault_mysql_root_password }}"
    php_version: "8.3"

  tasks:
    - name: Install Apache and PHP
      apt:
        name:
          - apache2
          - libapache2-mod-php{{ php_version }}
          - php{{ php_version }}
          - php{{ php_version }}-mysql
          - php{{ php_version }}-curl
          - php{{ php_version }}-xml
          - php{{ php_version }}-mbstring
        state: present
        update_cache: true

    - name: Install MySQL server
      apt:
        name: mysql-server
        state: present

    - name: Start and enable MySQL
      service:
        name: mysql
        state: started
        enabled: true

    - name: Start and enable Apache
      service:
        name: apache2
        state: started
        enabled: true

    - name: Deploy PHP info page for testing
      copy:
        content: "<?php phpinfo(); ?>"
        dest: /var/www/html/info.php
        owner: www-data
        group: www-data
        mode: "0644"

User management across servers

# users.yml
---
- name: Manage user accounts
  hosts: all
  become: true
  vars:
    admin_users:
      - name: jcarlos
        key: "ssh-ed25519 AAAAC3Nza... jcarlos@workstation"
        groups: sudo,adm
      - name: deployer
        key: "ssh-ed25519 AAAAC3Nza... deployer@ci"
        groups: www-data
    removed_users:
      - oldadmin
      - tempuser

  tasks:
    - name: Create admin users
      user:
        name: "{{ item.name }}"
        groups: "{{ item.groups }}"
        shell: /bin/bash
        create_home: true
        state: present
      loop: "{{ admin_users }}"

    - name: Add SSH keys for admin users
      authorized_key:
        user: "{{ item.name }}"
        key: "{{ item.key }}"
        exclusive: true
      loop: "{{ admin_users }}"

    - name: Remove old users
      user:
        name: "{{ item }}"
        state: absent
        remove: true
      loop: "{{ removed_users }}"

Security hardening

# harden.yml
---
- name: Security hardening
  hosts: all
  become: true

  tasks:
    - name: Install security packages
      apt:
        name:
          - ufw
          - fail2ban
          - unattended-upgrades
        state: present
        update_cache: true

    - name: Configure UFW defaults
      ufw:
        direction: incoming
        policy: deny

    - name: Allow SSH through UFW
      ufw:
        rule: allow
        port: "2222"
        proto: tcp

    - name: Allow HTTP and HTTPS
      ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "80"
        - "443"

    - name: Enable UFW
      ufw:
        state: enabled

    - name: Deploy fail2ban configuration
      copy:
        src: files/jail.local
        dest: /etc/fail2ban/jail.local
      notify: Restart fail2ban

    - name: Enable automatic security updates
      copy:
        content: |
          APT::Periodic::Update-Package-Lists "1";
          APT::Periodic::Unattended-Upgrade "1";
          APT::Periodic::AutocleanInterval "7";
        dest: /etc/apt/apt.conf.d/20auto-upgrades

    - name: Disable root SSH login
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "^PermitRootLogin"
        line: "PermitRootLogin no"
      notify: Restart sshd

  handlers:
    - name: Restart fail2ban
      service:
        name: fail2ban
        state: restarted

    - name: Restart sshd
      service:
        name: sshd
        state: restarted

Ansible Vault for Secrets

Never store passwords or API keys in plain text. Ansible Vault encrypts sensitive data:

# Create an encrypted variables file
ansible-vault create group_vars/all/vault.yml

# Edit an encrypted file
ansible-vault edit group_vars/all/vault.yml

# Encrypt an existing file
ansible-vault encrypt secrets.yml

# Run a playbook with vault
ansible-playbook site.yml --ask-vault-pass

# Use a password file instead
ansible-playbook site.yml --vault-password-file ~/.vault_pass

Inside the vault file, prefix sensitive variables with vault_:

# group_vars/all/vault.yml (encrypted)
vault_mysql_root_password: "s3cur3P@ssw0rd"
vault_api_key: "abc123def456"
vault_ssl_private_key: |
  -----BEGIN PRIVATE KEY-----
  ...
  -----END PRIVATE KEY-----

Reference vault variables in your regular variables file:

# group_vars/all/vars.yml
mysql_root_password: "{{ vault_mysql_root_password }}"
api_key: "{{ vault_api_key }}"

Troubleshooting

SSH connection failures

# Test SSH connectivity manually
ssh -i ~/.ssh/id_ed25519 deployer@web1.example.com

# Run with verbose mode to see SSH details
ansible all -m ping -i inventory.ini -vvvv

# Check if Python is available on the remote host
ansible all -m raw -a "python3 --version" -i inventory.ini

Permission denied errors

# Ensure become (sudo) is configured
ansible-playbook site.yml -i inventory.ini --become --ask-become-pass

# Check sudo configuration on the remote host
ssh deployer@web1.example.com "sudo -l"

Module not found errors

# List all available modules
ansible-doc -l | grep apt

# View documentation for a specific module
ansible-doc apt

# Check Ansible version for module compatibility
ansible --version

Playbook syntax errors

# Validate playbook syntax without executing
ansible-playbook site.yml --syntax-check

# Lint your playbook for best practices
pip install ansible-lint
ansible-lint site.yml

Debugging task output

- name: Run command and capture output
  command: systemctl status nginx
  register: nginx_status
  ignore_errors: true

- name: Display the output
  debug:
    var: nginx_status.stdout_lines

Essential Commands Reference

CommandDescription
ansible --versionDisplay Ansible version and configuration
ansible all -m ping -i inventory.iniTest connectivity to all hosts
ansible all -m setup -i inventory.iniGather and display host facts
ansible webservers -m apt -a "name=nginx state=present" -i inventory.ini --becomeInstall nginx on webservers via ad-hoc command
ansible-playbook site.yml -i inventory.iniExecute a playbook
ansible-playbook site.yml --check --diffDry run with diff output
ansible-playbook site.yml --limit web1.example.comRun playbook on a single host
ansible-playbook site.yml --tags "nginx,security"Run only tasks with specific tags
ansible-playbook site.yml --start-at-task "Install nginx"Resume playbook from a specific task
ansible-vault create secrets.ymlCreate a new encrypted file
ansible-vault edit secrets.ymlEdit an encrypted file
ansible-galaxy init roles/myroleCreate a new role directory structure
ansible-galaxy install geerlingguy.nginxInstall a role from Ansible Galaxy
ansible-inventory -i inventory.ini --graphDisplay inventory host graph
ansible-doc aptView module documentation

Summary

Ansible provides a powerful yet accessible path to infrastructure automation. Its agentless architecture means you can start automating immediately — if you can SSH into your servers, you can manage them with Ansible. Begin with simple ad-hoc commands, progress to playbooks for repeatable tasks, and organize complex configurations into roles. The combination of YAML readability, idempotent execution, and thousands of built-in modules makes Ansible the go-to tool for configuration management across Linux and Windows environments.

For provisioning the infrastructure itself — creating cloud servers, networks, and DNS records before configuring them — explore our Terraform infrastructure as code guide. To ensure the servers Ansible manages are secure from the start, follow our SSH hardening guide to lock down remote access with key-based authentication, fail2ban, and firewall rules.