Ansible: Server Automation and Configuration Management (2026)

Ansible is the most widely used configuration management and automation tool in the industry. Unlike Puppet or Chef, it is agentless — it connects to remote hosts over SSH and executes tasks, requiring nothing installed on the managed nodes. This guide covers everything from basic inventory to production-grade playbooks with roles, Vault secrets, and Jinja2 templating.

Ansible Architecture

Ansible's architecture is deliberately simple:

  • Control node: your machine or a CI server where Ansible is installed and playbooks are run.
  • Managed nodes: the remote servers. No agent required — only SSH access and Python 3 (pre-installed on virtually all Linux systems).
  • Inventory: the list of managed nodes and how they are grouped.
  • Modules: the units of work (install a package, copy a file, restart a service). Ansible ships 3,000+ modules.
  • Playbooks: YAML files that declare which tasks to run on which hosts.
  • Plugins: extend Ansible with custom connection types, callbacks, lookups, and filters.
# Install Ansible on Ubuntu/Debian control node
sudo apt update && sudo apt install -y ansible

# Verify
ansible --version
# ansible [core 2.17.x]

# Test connectivity to all inventory hosts
ansible all -m ping -i inventory/hosts.yml

Inventory Formats

Ansible supports INI, YAML, and dynamic inventory scripts. YAML is recommended for readability:

# inventory/hosts.yml
all:
  children:
    webservers:
      hosts:
        web01:
          ansible_host: 10.0.1.10
          ansible_user: ubuntu
        web02:
          ansible_host: 10.0.1.11
          ansible_user: ubuntu
      vars:
        http_port: 80
        nginx_version: "1.26"

    dbservers:
      hosts:
        db01:
          ansible_host: 10.0.2.10
          ansible_user: ubuntu
          ansible_ssh_private_key_file: ~/.ssh/db_key

    monitoring:
      hosts:
        mon01:
          ansible_host: 10.0.3.10
# INI format (older, still common)
# inventory/hosts.ini
[webservers]
web01 ansible_host=10.0.1.10 ansible_user=ubuntu
web02 ansible_host=10.0.1.11 ansible_user=ubuntu

[webservers:vars]
http_port=80

[dbservers]
db01 ansible_host=10.0.2.10

[production:children]
webservers
dbservers
Dynamic Inventory: For cloud environments, use Ansible's dynamic inventory plugins instead of static files. The AWS EC2 plugin (amazon.aws.aws_ec2) queries AWS and returns hosts by tag, region, or instance type. Configure with aws_ec2.yml in your inventory directory.

Playbook Anatomy

A playbook is a YAML file containing one or more plays. Each play targets a group of hosts and runs a list of tasks:

# playbooks/webserver.yml
---
- name: Configure and deploy web servers
  hosts: webservers
  become: true          # sudo
  gather_facts: true    # collect host info (OS, IP, etc.)

  vars:
    app_user: deploy
    app_dir: /opt/myapp
    nginx_config_path: /etc/nginx/sites-available/myapp

  pre_tasks:
    - name: Update apt cache
      ansible.builtin.apt:
        update_cache: true
        cache_valid_time: 3600

  tasks:
    - name: Install Nginx
      ansible.builtin.apt:
        name: nginx
        state: present

    - name: Create application directory
      ansible.builtin.file:
        path: "{{ app_dir }}"
        state: directory
        owner: "{{ app_user }}"
        mode: "0755"

    - name: Deploy Nginx configuration
      ansible.builtin.template:
        src: templates/nginx.conf.j2
        dest: "{{ nginx_config_path }}"
        owner: root
        group: root
        mode: "0644"
      notify: Reload Nginx

    - name: Enable site
      ansible.builtin.file:
        src: "{{ nginx_config_path }}"
        dest: /etc/nginx/sites-enabled/myapp
        state: link
      notify: Reload Nginx

  handlers:
    - name: Reload Nginx
      ansible.builtin.service:
        name: nginx
        state: reloaded

  post_tasks:
    - name: Verify Nginx is running
      ansible.builtin.uri:
        url: "http://{{ ansible_host }}/health"
        status_code: 200
      retries: 3
      delay: 5

Roles Structure

Roles are the Ansible way to organize reusable automation. A role is a directory with a conventional structure:

roles/
└── nginx/
    ├── tasks/
    │   └── main.yml        # task list
    ├── handlers/
    │   └── main.yml        # handlers (notified by tasks)
    ├── templates/
    │   └── nginx.conf.j2   # Jinja2 templates
    ├── files/
    │   └── index.html      # static files to copy
    ├── vars/
    │   └── main.yml        # role-internal variables (high precedence)
    ├── defaults/
    │   └── main.yml        # default values (lowest precedence — override freely)
    ├── meta/
    │   └── main.yml        # role metadata, dependencies
    └── README.md
# roles/nginx/defaults/main.yml
nginx_user: www-data
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_keepalive_timeout: 65
http_port: 80
https_port: 443

# roles/nginx/tasks/main.yml
---
- name: Install Nginx
  ansible.builtin.apt:
    name: "nginx={{ nginx_version | default('*') }}"
    state: present
    update_cache: true

- name: Configure Nginx
  ansible.builtin.template:
    src: nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  notify: Restart Nginx

- name: Ensure Nginx is enabled and started
  ansible.builtin.service:
    name: nginx
    state: started
    enabled: true
# Use the role in a playbook
- name: Set up web servers
  hosts: webservers
  become: true
  roles:
    - role: nginx
      vars:
        http_port: 8080
    - role: app_deploy
    - role: monitoring_agent

Jinja2 Templates

Ansible uses Jinja2 for templating configuration files and constructing dynamic values. Templates live in templates/*.j2 and are rendered with the template module:

# templates/nginx.conf.j2
user {{ nginx_user }};
worker_processes {{ nginx_worker_processes }};
pid /run/nginx.pid;

events {
    worker_connections {{ nginx_worker_connections }};
}

http {
    sendfile on;
    keepalive_timeout {{ nginx_keepalive_timeout }};

    {% for vhost in virtual_hosts %}
    server {
        listen {{ http_port }};
        server_name {{ vhost.name }};

        location / {
            proxy_pass http://{{ vhost.backend_host }}:{{ vhost.backend_port }};
            proxy_set_header Host $host;
            proxy_set_header X-Real-IP $remote_addr;
        }

        {% if vhost.enable_gzip | default(false) %}
        gzip on;
        gzip_types text/plain application/json;
        {% endif %}
    }
    {% endfor %}
}

Variable Precedence

Ansible has 22 levels of variable precedence. Simplified from lowest to highest priority:

  1. Role defaults (defaults/main.yml) — lowest, designed to be overridden
  2. Inventory group vars (group_vars/all.yml, group_vars/webservers.yml)
  3. Inventory host vars (host_vars/web01.yml)
  4. Playbook vars: block
  5. Role vars (vars/main.yml)
  6. include_vars and set_fact
  7. Extra vars (-e "key=value" on command line) — highest, overrides everything
Best practice: Put tunable values in defaults/main.yml. Put internal constants the role depends on in vars/main.yml. Use group_vars/ and host_vars/ for environment-specific overrides.

Ansible Vault

Ansible Vault encrypts sensitive data (passwords, API keys, certificates) so you can safely commit them to source control:

# Encrypt a file
ansible-vault encrypt group_vars/production/secrets.yml
# prompts for vault password

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

# Encrypt a single value (inline vault)
ansible-vault encrypt_string 'supersecret123' --name 'db_password'
# outputs:
# db_password: !vault |
#   $ANSIBLE_VAULT;1.1;AES256
#   61383937...

# Run a playbook with vault
ansible-playbook site.yml --ask-vault-pass
# or with a password file (for CI/CD):
ansible-playbook site.yml --vault-password-file ~/.vault_pass
# group_vars/production/secrets.yml (encrypted at rest)
db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  61383937303632613031623936303530
  ...

aws_secret_key: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  34623035613130623033376163376161
  ...

Essential Modules

ModulePurposeExample
ansible.builtin.apt / yum / dnfPackage managementname: nginx state: present
ansible.builtin.copyCopy local file to remotesrc: files/app.conf dest: /etc/app.conf
ansible.builtin.templateRender Jinja2 template to remotesrc: nginx.j2 dest: /etc/nginx/nginx.conf
ansible.builtin.serviceStart/stop/enable servicesname: nginx state: started enabled: true
ansible.builtin.userManage OS usersname: deploy shell: /bin/bash groups: sudo
ansible.builtin.fileManage files, dirs, symlinkspath: /opt/app state: directory mode: "0755"
ansible.builtin.shell / commandRun shell commandsUse command for simple; shell for pipes/redirects
ansible.builtin.lineinfileEnsure a line exists in a filepath: /etc/hosts line: "10.0.1.1 db01"
ansible.builtin.gitClone/checkout a reporepo: https://github.com/... dest: /opt/app
ansible.builtin.uriHTTP requests (health checks)url: http://localhost/health status_code: 200

Idempotency

Idempotency means running the same playbook multiple times produces the same result without unintended side effects. Ansible modules are idempotent by design — apt: name=nginx state=present installs Nginx if absent, does nothing if already installed.

When writing custom tasks, ensure idempotency with conditions:

# Non-idempotent (runs every time, potentially harmful):
- name: Add line to config
  ansible.builtin.shell: echo "max_connections=200" >> /etc/postgresql/pg.conf

# Idempotent (only adds line if absent):
- name: Set max_connections in postgres config
  ansible.builtin.lineinfile:
    path: /etc/postgresql/14/main/postgresql.conf
    regexp: '^max_connections'
    line: 'max_connections = 200'
    backup: true

# Idempotent command with creates: guard
- name: Initialize application database
  ansible.builtin.command:
    cmd: /opt/app/bin/init-db.sh
    creates: /opt/app/.db_initialized  # skip if this file exists

Ansible vs Terraform

AspectAnsibleTerraform
Primary purposeConfiguration management, app deploymentInfrastructure provisioning
State managementStateless (checks live state each run)State file (tracks what was created)
LanguageYAML + Jinja2HCL (HashiCorp Config Language)
AgentlessYes (SSH)Yes (cloud APIs)
IdempotencyModule-level (ensure modules handle it)Built-in (plan/apply model)
Best forOS config, software install, deploymentsCreating VMs, VPCs, RDS, EKS clusters
Use together?Yes — Terraform provisions infrastructure, Ansible configures it

FAQ

Do managed nodes need Python installed?
Most Ansible modules require Python 3 on the managed node (this is pre-installed on all modern Linux distributions). For minimal containers or network devices, use ansible_connection=network_cli or the raw module, which uses only SSH without Python.
What is the difference between shell and command modules?
The command module runs a command directly without a shell — no pipes (|), redirects (>), or environment variable expansion. The shell module invokes /bin/sh, supporting all shell features. Use command by default for security and predictability; use shell only when you need shell features.
How do I run a playbook against only one host from a group?
Use the --limit flag: ansible-playbook site.yml --limit web01. You can also limit to a pattern: --limit "web*" or to a subset: --limit "webservers[0]" (first host in the group).
What is AWX and when should I use it?
AWX is the open-source upstream of Red Hat Ansible Automation Platform (formerly Ansible Tower). It provides a web UI, role-based access control, job scheduling, and audit logs for Ansible. Use it when you need multiple team members running playbooks with controlled permissions, or when you want scheduled automation runs without managing cron jobs.
How do I handle different Linux distributions in one playbook?
Use ansible_os_family and ansible_distribution facts with when: conditions: when: ansible_os_family == "Debian" for apt-based tasks and when: ansible_os_family == "RedHat" for yum/dnf tasks. Or use the package module (distribution-agnostic) for simple installs.