Ansible for Linux Automation: Complete Beginner Guide

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.

Contents
  1. Prerequisites
  2. How Ansible Works
  3. Install Ansible
  4. Inventory Files
  5. Ad-Hoc Commands
  6. Your First Playbook
  7. Variables and group_vars
  8. Jinja2 Templates
  9. Handlers
  10. RolesFurther Reading

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, not changed
  • 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 --version

Expected 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.2

Inventory 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-hosts

Store 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 --become

Your 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.com

A 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=0

Variables 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.yml

Variable 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 Nginx

The 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: reloaded

Even 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.

Roles


Go up