Skip to content

Ansible Automation: Inventory, Playbooks & Roles

Configuration management that scales — inventories, idempotent playbooks, reusable roles, and secrets management with Vault.

AnsibleYAMLVaultSSH
RR

Ranjith R

Linux SysAdmin & Cloud Engineer

Published March 10, 2026Updated April 22, 2026
7 min readIntermediate#Ansible#Automation#IaC#Configuration Management
Share
Section 01

Introduction

Ansible is a configuration management and automation tool that describes the desired state of a fleet of machines as plain YAML, then pushes that state out over SSH — no agents to install, no daemons to keep patched on every managed host. You write what the end state should look like (a package installed, a service running, a config file templated with the right values), and Ansible figures out whether anything needs to change on each run.

This guide covers the full path from a single ad-hoc command to a production-grade, role-based deployment: inventories (static and dynamic), playbooks and variable precedence, roles and handlers, and secrets management with Ansible Vault. The worked examples — server hardening, Nginx installation, and a zero-downtime rolling restart across a web fleet — are the same patterns used to manage real production infrastructure.

Who this is for

  • Linux administrators moving from one-off shell scripts to repeatable automation.
  • Anyone managing more than a handful of servers by hand and feeling the pain.
  • DevOps engineers who need configuration management alongside CI/CD and cloud provisioning.
Section 02

Prerequisites

You should have the following before working through the commands in this guide:

  • A control node — any Linux machine (or WSL) with Python 3.9+ installed.
  • At least one managed host reachable over SSH, ideally two or three to see groups in action.
  • SSH key-based access from the control node to each managed host (ssh-copy-id already run).
  • sudo/root privileges on the managed hosts for tasks that need privilege escalation.
  • Basic YAML familiarity — indentation is meaningful and this trips up almost everyone at first.

No agent required

Ansible needs nothing installed on managed hosts beyond Python and SSH access, which ship on virtually every Linux distribution by default. All the logic lives on the control node.
Section 03

Theory

Agentless, push-based architecture

Tools like Puppet or Chef run a persistent agent on every managed node that periodically pulls configuration from a central server. Ansible inverts this: a single control node pushes instructions to managed hosts over standard SSH, executes a small Python module on the remote end, and then removes it. There is nothing running in the background on managed hosts between runs.

Idempotency

Every built-in Ansible module is designed to be idempotent — running the same playbook twice in a row produces the same end state, and the second run reports no changes if nothing needs to change. A task that says state: presentfor a package checks whether it's already installed before doing anything; a task that templates a config file only rewrites it if the rendered content actually differs. This is what makes playbooks safe to re-run constantly, including on a schedule or in CI, without side effects piling up.

Declarative, not procedural

A playbook describes the desired state("Nginx should be installed and running"), not a sequence of imperative steps to get there. Ansible's modules contain the logic to inspect current state and reconcile it — you rarely need conditionals to check "is this already done?" the way you would in a raw shell script.

Facts

At the start of a play, Ansible gathers facts — OS family, IP addresses, memory, mounted disks — from each managed host via the setup module, and exposes them as variables (ansible_facts, or directly as ansible_distribution, etc.) that tasks and templates can branch on.

Section 04

Architecture

The control node reads an inventory that groups managed hosts, then connects to each one over SSH to run modules — no software is required on the managed side beyond Python and an SSH daemon:

Control node reads the inventory, then pushes modules to each managed host over SSH

Dynamic inventory

Static files work well for a handful of hosts. At cloud scale, a dynamic inventory plugin (amazon.aws.aws_ec2, azure.azcollection.azure_rm, etc.) queries the cloud provider's API at run time and builds the host list and groups automatically from tags, so the inventory never goes stale as instances are created and destroyed.
Section 05

Installation

Install Ansible on the control node only — never on the managed hosts.

terminal
# Official PPA gets you the latest stable release
sudo apt update
sudo apt install -y software-properties-common
sudo add-apt-repository --yes --update ppa:ansible/ansible
sudo apt install -y ansible

# Verify
ansible --version

ansible vs ansible-core

The ansible package bundles a large set of community collections; ansible-core is the minimal engine. Production pipelines often pin ansible-core plus an explicit requirements.yml of only the collections they use, for reproducible, faster installs.
Section 06

Configuration

ansible.cfg

Ansible reads configuration from (in order of precedence) the ANSIBLE_CONFIG environment variable, ./ansible.cfg in the current directory, ~/.ansible.cfg, then /etc/ansible/ansible.cfg. A project-local ansible.cfg keeps settings versioned alongside the playbooks that need them.

ansible.cfg
[defaults]
inventory       = ./inventory/hosts.yml
remote_user     = deploy
host_key_checking = True
retry_files_enabled = False
roles_path      = ./roles
interpreter_python = auto_silent

[privilege_escalation]
become          = True
become_method   = sudo
become_user     = root
become_ask_pass = False

[ssh_connection]
pipelining      = True
control_path    = ~/.ansible/cp/%%h-%%p-%%r

Static inventory — INI format

inventory/hosts.ini
[webservers]
web01.internal ansible_host=10.0.1.11
web02.internal ansible_host=10.0.1.12

[dbservers]
db01.internal ansible_host=10.0.2.11

[webservers:vars]
http_port=80

# A group of groups
[production:children]
webservers
dbservers

Static inventory — YAML format

The YAML form is preferred for larger inventories because it nests groups and variables more clearly than the INI format:

inventory/hosts.yml
all:
  children:
    webservers:
      hosts:
        web01.internal:
          ansible_host: 10.0.1.11
        web02.internal:
          ansible_host: 10.0.1.12
      vars:
        http_port: 80
    dbservers:
      hosts:
        db01.internal:
          ansible_host: 10.0.2.11
    production:
      children:
        webservers:
        dbservers:

Host and group patterns

terminal
ansible webservers -m ping                 # one group
ansible 'webservers:dbservers' -m ping      # union of two groups
ansible 'webservers:!web02*' -m ping        # exclude a host
ansible 'web0[1:2].internal' -m ping        # numeric range pattern
ansible all -m ping                         # every host in inventory

Variables and precedence

Variables can be defined in a dozen different places, which is powerful but easy to get wrong. From lowest to highestprecedence (simplified to the ones you'll actually hit day to day):

SourceScope
Role defaults/main.ymlLowest — easily overridden defaults
Inventory group vars (group_vars/all.yml)All hosts
group_vars/<groupname>.ymlOne group
host_vars/<hostname>.ymlOne host
Play vars: / vars_files:Current play
Role vars/main.ymlCurrent role, harder to override than defaults
Task-level vars:Single task
-e / --extra-vars on the CLIHighest — always wins
terminal
# Directory layout Ansible auto-loads variables from
inventory/
  hosts.yml
  group_vars/
    all.yml            # applies to every host
    webservers.yml      # applies only to the webservers group
  host_vars/
    web01.internal.yml  # applies only to this one host
group_vars/webservers.yml
http_port: 80
nginx_worker_processes: auto
app_env: production

# vars_files can also be referenced explicitly from a play
# vars_files:
#   - vars/nginx_defaults.yml

Jinja2 templating

Variables are interpolated into tasks and config files with {{ variable }} Jinja2 syntax. The template module renders a .j2 file on the control node and copies the result to the managed host:

templates/nginx.conf.j2
server {
    listen {{ http_port }};
    server_name {{ inventory_hostname }};

    location / {
        root /var/www/{{ app_env }};
        index index.html;
    }
}
Section 07

Commands

ansible — ad-hoc commands

Ad-hoc commands run a single module against a set of hosts without writing a playbook — ideal for quick checks and one-off fixes.

terminal
ansible all -m ping                                    # connectivity + Python check
ansible webservers -a "uptime"                          # raw command via the default 'command' module
ansible webservers -m shell -a "df -h" -b                # -b = become (sudo)
ansible webservers -m yum -a "name=httpd state=latest" -b
ansible webservers -m service -a "name=nginx state=restarted" -b
ansible webservers -m copy -a "src=motd dest=/etc/motd" -b
ansible all -m setup                                      # dump gathered facts
ansible all -m setup -a "filter=ansible_distribution*"    # filtered facts

ansible-playbook

terminal
ansible-playbook site.yml                        # run the full playbook
ansible-playbook site.yml --check                # dry run, no changes made
ansible-playbook site.yml --diff                 # show file/content diffs
ansible-playbook site.yml --limit webservers      # restrict to a group/host
ansible-playbook site.yml --tags nginx            # run only tagged tasks
ansible-playbook site.yml --skip-tags hardening
ansible-playbook site.yml -e "app_version=2.4.1"  # override a variable
ansible-playbook site.yml -i inventory/staging.yml
ansible-playbook site.yml -vvv                    # verbose, for debugging

ansible-vault — secrets management

terminal
ansible-vault create secrets.yml            # create + encrypt a new file
ansible-vault edit secrets.yml              # decrypt, open in $EDITOR, re-encrypt
ansible-vault view secrets.yml              # print decrypted contents
ansible-vault encrypt vars/prod.yml         # encrypt an existing plaintext file
ansible-vault decrypt vars/prod.yml         # permanently decrypt (careful!)
ansible-vault rekey secrets.yml             # change the vault password

# Encrypt a single string inline for use in an otherwise plaintext file
ansible-vault encrypt_string 'S3cretPass!' --name 'db_password'

# Running a playbook that touches vaulted files
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt
Section 08

Examples

A complete playbook that hardens a fresh host and installs Nginx — a realistic "day one" provisioning play:

harden-and-nginx.yml
---
- name: Harden baseline and install Nginx
  hosts: webservers
  become: true
  vars:
    allowed_ssh_users:
      - deploy
    ssh_port: 22

  tasks:
    - name: Ensure required packages are present
      ansible.builtin.package:
        name:
          - nginx
          - fail2ban
          - ufw
        state: present

    - name: Disable root SSH login
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PermitRootLogin'
        line: 'PermitRootLogin no'
        validate: '/usr/sbin/sshd -T -f %s'
      notify: Restart sshd

    - name: Disable SSH password authentication
      ansible.builtin.lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^#?PasswordAuthentication'
        line: 'PasswordAuthentication no'
        validate: '/usr/sbin/sshd -T -f %s'
      notify: Restart sshd

    - name: Allow SSH and HTTP through ufw
      community.general.ufw:
        rule: allow
        port: "{{ item }}"
        proto: tcp
      loop:
        - "{{ ssh_port }}"
        - "80"
        - "443"

    - name: Enable ufw
      community.general.ufw:
        state: enabled
        policy: deny

    - name: Deploy Nginx virtual host from template
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/default
        owner: root
        group: root
        mode: "0644"
      notify: Reload nginx

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

  handlers:
    - name: Restart sshd
      ansible.builtin.service:
        name: sshd
        state: restarted

    - name: Reload nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded
  1. Write the playbook and inventory

    Define webservers in the inventory and write the tasks above in harden-and-nginx.yml.
  2. Dry-run before touching production

    ansible-playbook harden-and-nginx.yml --check --diff shows exactly what would change without making any modification.
  3. Run it for real

    ansible-playbook harden-and-nginx.yml applies the changes; handlers only fire once, at the end of the play, even if multiple tasks notify the same handler.
  4. Re-run to confirm idempotency

    Running the exact same command again should report changed=0 for every task — proof the play is safe to schedule or re-apply at any time.
Section 09

Screenshots

ansible-playbook run output — per-task ok/changed/failed summary with the final PLAY RECAP

Screenshot placeholder

ansible-playbook --check --diff showing a templated nginx.conf change before it is applied

Screenshot placeholder

Section 10

Real World Example

Scenario: a fleet of six Nginx web servers behind a load balancer needs a new release deployed. Restarting all six at once would drop traffic during the restart window, so the rollout must go through the load balancer one host at a time.

Role directory structure, scaffolded with ansible-galaxy init:

terminal
ansible-galaxy init roles/webapp

roles/webapp/
  tasks/main.yml       # the work: install, template, notify handlers
  handlers/main.yml    # restart/reload actions triggered by notify
  templates/           # .j2 config/app files
  files/                # static files copied verbatim
  vars/main.yml         # role-specific, high-precedence variables
  defaults/main.yml     # role-specific, low-precedence variables (safe overrides)
  meta/main.yml          # role metadata and dependencies
roles/webapp/defaults/main.yml
app_release_version: "2.4.1"
app_artifact_url: "https://artifacts.internal.example.com/webapp/{{ app_release_version }}.tar.gz"
app_dir: /srv/webapp
roles/webapp/tasks/main.yml
---
- name: Drain this host from the load balancer
  community.general.haproxy:
    state: disabled
    backend: web_backend
    host: "{{ inventory_hostname }}"
    socket: /var/run/haproxy.sock
  delegate_to: lb01.internal

- name: Fetch and unpack the new release
  ansible.builtin.unarchive:
    src: "{{ app_artifact_url }}"
    dest: "{{ app_dir }}"
    remote_src: true
  notify: Restart webapp

- name: Template application config
  ansible.builtin.template:
    src: app.conf.j2
    dest: "{{ app_dir }}/config/app.conf"
    mode: "0640"
  notify: Restart webapp

- name: Flush handlers now so the restart happens before re-enabling
  ansible.builtin.meta: flush_handlers

- name: Wait for the app to answer health checks locally
  ansible.builtin.uri:
    url: "http://127.0.0.1:8080/healthz"
    status_code: 200
  register: health
  retries: 10
  delay: 3
  until: health.status == 200

- name: Re-enable this host in the load balancer
  community.general.haproxy:
    state: enabled
    backend: web_backend
    host: "{{ inventory_hostname }}"
    socket: /var/run/haproxy.sock
  delegate_to: lb01.internal
roles/webapp/handlers/main.yml
---
- name: Restart webapp
  ansible.builtin.service:
    name: webapp
    state: restarted
deploy-webapp.yml
---
- name: Rolling zero-downtime webapp deploy
  hosts: webservers
  become: true
  serial: 1              # one host at a time — the key to zero downtime
  max_fail_percentage: 0 # stop the whole run if any host fails
  roles:
    - webapp
  1. serial: 1 controls the batch size

    Ansible processes exactly one host through the entire role — drain, deploy, health check, re-enable — before starting the next, so at least five of six backends are always serving traffic.
  2. Drain before touching the app

    delegate_to runs the HAProxy task against the load balancer, not the web host, removing this one host from rotation before its service is restarted.
  3. Handlers batch the restart

    Both the unarchive and template tasks notify the same Restart webapp handler, but it only runs once per host — no redundant restarts even if both tasks report a change.
  4. Health check gates re-enabling

    until + retries on the uri task blocks until the freshly restarted app actually answers /healthz, so a broken release never gets re-added to the load balancer.
Troubleshooting

Common Issues

SSH connectivity / host key checking failures

UNREACHABLE!is almost always SSH: wrong user, missing key, or a host key prompt Ansible can't answer non-interactively. Test with ssh -i key.pem user@host directly first, and either accept the host key once manually or set host_key_checking = False in ansible.cfg for ephemeral/CI environments only.

become / privilege escalation failures

Missing sudo password means the remote user needs NOPASSWD in sudoers or you need --ask-become-pass. Confirm with sudo -l as that user on the managed host before assuming the playbook is wrong.

YAML indentation errors

Ansible YAML is whitespace-sensitive and tabs are invalid — a task misaligned by one space silently becomes a sibling of the wrong key. Run ansible-playbook site.yml --syntax-check before every real run, and use an editor with YAML linting enabled.

Idempotency broken by shell/command modules

shell and commandhave no built-in concept of "already done" — they report changed on every run. Guard them with creates:/removes:, a preceding check task with register + when, or better, replace them with a proper idempotent module (package, copy, lineinfile) where one exists.

Vault password mismatch

Decryption failed means the wrong vault password or password file was supplied, or the file was encrypted with a different vault ID. Check for multiple vault IDs with --vault-id label@path if different environments use different vault passwords.
Best Practices

Best Practices

  • Keep inventories and playbooks in version control; treat them with the same review process as application code.
  • Prefer roles over one giant playbook — they're reusable, testable in isolation, and easy to share via Ansible Galaxy.
  • Always run --check --diff against production before the real apply, especially for config file changes.
  • Use group_vars/host_vars instead of scattering -e overrides across ad-hoc CLI invocations.
  • Pin collection and role versions in requirements.yml so a fresh checkout behaves identically months later.
  • Tag tasks meaningfully (--tags/--skip-tags) so a huge playbook can still be run selectively during an incident.
Security

Security Hardening

  • Ansible Vault — never commit plaintext secrets; encrypt entire files or individual strings with ansible-vault encrypt_string.
  • no_log — set no_log: true on any task that handles passwords or tokens so they never appear in verbose output or logs.
  • Least-privilege become — scope become_user to the specific account a task actually needs rather than defaulting every play to root.
  • SSH key-based auth — the control node should authenticate to managed hosts with keys only, ideally through an SSH agent, never stored passwords.
  • Vault password files — keep the vault password file outside version control and restrict its permissions to 0600; consider a secrets manager (HashiCorp Vault, AWS Secrets Manager) for the password itself in CI.
Interview Questions

Interview Questions

Ansible is agentless and push-based — a control node connects out over SSH and runs modules on demand. Puppet and Chef are agent-based and typically pull-based, with a persistent agent on every managed node periodically checking in with a central server.
Cheat Sheet

Cheat Sheet

ansible-cheatsheet.sh
# --- Connectivity / ad-hoc ---
ansible all -m ping
ansible webservers -a "uptime"
ansible webservers -m shell -a "df -h" -b
ansible all -m setup -a "filter=ansible_distribution*"

# --- Playbooks ---
ansible-playbook site.yml
ansible-playbook site.yml --check --diff
ansible-playbook site.yml --limit webservers --tags nginx
ansible-playbook site.yml -e "app_version=2.4.1"
ansible-playbook site.yml --syntax-check
ansible-playbook site.yml -vvv

# --- Vault ---
ansible-vault create secrets.yml
ansible-vault edit secrets.yml
ansible-vault encrypt_string 'secret' --name 'db_password'
ansible-playbook site.yml --vault-password-file ~/.vault_pass.txt

# --- Roles ---
ansible-galaxy init roles/myrole
ansible-galaxy install -r requirements.yml

# --- Inventory ---
ansible-inventory -i inventory/hosts.yml --graph
ansible-inventory -i inventory/hosts.yml --list
Summary

Summary

Ansible turns fleet management into version-controlled, idempotent YAML instead of tribal knowledge and one-off shell scripts. The core mental model carries everything else: an inventory groups hosts, playbooks describe desired state through modules, roles package that logic for reuse, handlers batch disruptive actions like restarts, and Vault keeps secrets out of plaintext. Master ad-hoc commands for quick fixes, then graduate to role-based playbooks with serial and health checks for safe, repeatable production deployments.

Resources

Resources

  • Official Ansible documentation — docs.ansible.com
  • Ansible Galaxy (community roles and collections) — galaxy.ansible.com
  • Ansible source and issue tracker — github.com/ansible/ansible
  • Red Hat Ansible Automation Platform docs — docs.redhat.com
  • Jinja2 templating reference — jinja.palletsprojects.com