Ansible: The Complete Guide for 2026

Published February 12, 2026 · 30 min read

Ansible automates the tedious parts of managing servers. Instead of SSH-ing into machines one at a time, running commands, and hoping you remembered every step, you write a YAML playbook that describes what each server should look like. Ansible connects over SSH, makes the changes, and reports back. No agents to install, no master server to maintain, no new language to learn beyond YAML.

This guide covers Ansible from installation through production patterns. Every section includes real playbook examples you can adapt immediately. Pair this with our Terraform Complete Guide for infrastructure provisioning and Docker Compose Guide for container orchestration.

Table of Contents

  1. What Is Ansible and Why It Matters
  2. Installing Ansible
  3. Inventory Files
  4. Ad-Hoc Commands
  5. Playbook Basics
  6. Modules
  7. Variables, Facts, and Jinja2 Templates
  8. Roles and Directory Structure
  9. Ansible Galaxy
  10. Ansible Vault for Secrets
  11. Error Handling
  12. Real-World Playbook Examples
  13. Ansible vs Terraform vs Chef vs Puppet
  14. Best Practices
  15. FAQ

What Is Ansible and Why It Matters

Ansible is an open-source automation engine maintained by Red Hat. It handles configuration management, application deployment, and orchestration through declarative YAML files called playbooks. Three properties set it apart from older tools like Chef and Puppet:

Agentless. Ansible connects to managed nodes over SSH. No daemon runs on target machines. Nothing to install, update, or secure on the hosts you manage. If you can SSH to a server, Ansible can manage it.

Idempotent. Running the same playbook twice produces the same result. If a package is already installed, Ansible skips it. If a config file already has the correct content, Ansible leaves it alone. This makes playbooks safe to run repeatedly.

YAML-based. Playbooks are YAML files, not Ruby (Chef) or a custom DSL (Puppet). Any developer who can read a config file can read an Ansible playbook. The learning curve is measured in hours, not weeks.

Ansible runs on a control node (your laptop, a CI server, or a bastion host) and manages remote nodes. The control node needs Python 3.9+ and the ansible package. Managed nodes need only Python 3 and an SSH server — both present by default on every modern Linux distribution.

Installing Ansible

Install Ansible on your control node (not on managed servers):

# Ubuntu/Debian
sudo apt update
sudo apt install -y pipx
pipx install --include-deps ansible

# macOS
brew install ansible

# pip (any platform)
pip install ansible

# Verify installation
ansible --version

Ansible 9+ (ansible-core 2.16+) requires Python 3.10+ on the control node. Managed nodes need Python 3.6+ minimum. The pipx method keeps Ansible in an isolated environment and is the recommended approach for 2026.

Inventory Files

The inventory tells Ansible which hosts to manage. You define groups of servers with connection details. Ansible supports INI and YAML formats.

INI format (simple and common):

# inventory.ini
[webservers]
web1.example.com
web2.example.com ansible_port=2222

[dbservers]
db1.example.com ansible_user=dbadmin
db2.example.com

[loadbalancers]
lb1.example.com

[production:children]
webservers
dbservers
loadbalancers

[all:vars]
ansible_python_interpreter=/usr/bin/python3

YAML format (better for complex inventories):

# inventory.yml
all:
  children:
    webservers:
      hosts:
        web1.example.com:
        web2.example.com:
          ansible_port: 2222
    dbservers:
      hosts:
        db1.example.com:
          ansible_user: dbadmin
        db2.example.com:
    production:
      children:
        webservers:
        dbservers:

The :children keyword creates groups of groups. all is a built-in group containing every host. Use ansible-inventory --list -i inventory.yml to verify your inventory structure. For cloud environments, use dynamic inventory plugins that query AWS EC2, Azure, or GCP for current instances automatically.

Ad-Hoc Commands

Ad-hoc commands run a single module against hosts without writing a playbook. They are useful for quick checks and one-off tasks:

# Ping all hosts (tests connectivity)
ansible all -i inventory.ini -m ping

# Check disk space on web servers
ansible webservers -m command -a "df -h"

# Install a package on db servers
ansible dbservers -m apt -a "name=htop state=present" --become

# Restart nginx on web servers
ansible webservers -m service -a "name=nginx state=restarted" --become

# Copy a file to all hosts
ansible all -m copy -a "src=./motd.txt dest=/etc/motd" --become

# Run with 10 parallel forks (default is 5)
ansible all -m ping -f 10

The --become flag escalates to root via sudo. The -m flag selects the module. The -a flag passes module arguments. Ad-hoc commands are for quick operations; use playbooks for anything you need to repeat.

Playbook Basics

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

# site.yml
---
- name: Configure web servers
  hosts: webservers
  become: true
  vars:
    http_port: 80
    doc_root: /var/www/html

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

    - name: Copy site configuration
      template:
        src: templates/nginx.conf.j2
        dest: /etc/nginx/sites-available/default
      notify: Restart nginx

    - name: Create document root
      file:
        path: "{{ doc_root }}"
        state: directory
        owner: www-data
        mode: "0755"

    - name: Deploy index page
      copy:
        content: "<h1>Hello from {{ inventory_hostname }}</h1>"
        dest: "{{ doc_root }}/index.html"

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

Run it with ansible-playbook -i inventory.ini site.yml.

Handlers are tasks that run only when notified by another task. If the nginx config file does not change, the handler never fires. This prevents unnecessary service restarts. Handlers run once at the end of all tasks, even if notified multiple times.

Key playbook elements:

Modules

Modules are the units of work in Ansible. Each module handles a specific task. Ansible ships with thousands of modules. Here are the ones you will use daily:

Package management:

# Debian/Ubuntu
- apt:
    name: [nginx, curl, htop]
    state: present
    update_cache: true

# RHEL/CentOS/Fedora
- yum:
    name: httpd
    state: latest

# Generic (auto-detects package manager)
- package:
    name: git
    state: present

File operations:

# Copy a file
- copy:
    src: files/app.conf
    dest: /etc/app/app.conf
    owner: root
    mode: "0644"

# Render a Jinja2 template
- template:
    src: templates/nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    validate: nginx -t -c %s
  notify: Reload nginx

# Create a directory
- file:
    path: /opt/myapp/logs
    state: directory
    owner: appuser
    mode: "0755"

# Download a file
- get_url:
    url: https://example.com/app-v2.tar.gz
    dest: /tmp/app-v2.tar.gz
    checksum: sha256:abc123...

Services and containers:

# Manage systemd services
- service:
    name: nginx
    state: started
    enabled: true

# Run a Docker container
- docker_container:
    name: redis
    image: redis:7-alpine
    state: started
    restart_policy: always
    ports:
      - "6379:6379"
    volumes:
      - redis_data:/data

# Execute a shell command
- shell: |
    cd /opt/myapp && ./deploy.sh
  args:
    creates: /opt/myapp/.deployed

Users and permissions:

- user:
    name: deploy
    groups: [sudo, docker]
    shell: /bin/bash
- authorized_key:
    user: deploy
    key: "{{ lookup('file', 'keys/deploy.pub') }}"

Every module is idempotent. state: present ensures something exists. state: absent removes it. state: latest ensures the newest version.

Variables, Facts, and Jinja2 Templates

Variables make playbooks reusable. Ansible has a well-defined variable precedence order with over 20 levels. The most common sources, from lowest to highest priority:

Facts are variables automatically gathered from managed nodes:

# Access system facts
{{ ansible_hostname }}
{{ ansible_os_family }}           # "Debian", "RedHat"
{{ ansible_default_ipv4.address }}
{{ ansible_memtotal_mb }}

# Disable fact gathering for speed: gather_facts: false

Jinja2 templates render dynamic config files. Create a template at templates/nginx.conf.j2:

server {
    listen {{ http_port }};
    server_name {{ server_name }};
    root {{ doc_root }};

{% for location in proxy_locations %}
    location {{ location.path }} {
        proxy_pass {{ location.backend }};
    }
{% endfor %}

{% if enable_ssl %}
    listen 443 ssl;
    ssl_certificate {{ ssl_cert_path }};
    ssl_certificate_key {{ ssl_key_path }};
{% endif %}
}

Conditionals and loops in tasks:

# Conditional execution
- name: Install Apache on RedHat
  yum:
    name: httpd
    state: present
  when: ansible_os_family == "RedHat"

# Loop over a list
- name: Create app users
  user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
  loop:
    - { name: alice, groups: "sudo,docker" }
    - { name: bob, groups: "docker" }

# Register output and use it
- name: Check if app is running
  command: pgrep myapp
  register: app_status
  ignore_errors: true

- name: Start app if not running
  command: /opt/myapp/start.sh
  when: app_status.rc != 0

Roles and Directory Structure

Roles organize playbooks into reusable components. A role has a fixed directory structure:

roles/
  webserver/
    tasks/
      main.yml          # Task list
    handlers/
      main.yml          # Handlers
    templates/
      nginx.conf.j2     # Jinja2 templates
    files/
      index.html        # Static files
    vars/
      main.yml          # Role variables (high priority)
    defaults/
      main.yml          # Default variables (low priority)
    meta/
      main.yml          # Role metadata and dependencies

Create a role scaffold with ansible-galaxy init roles/webserver.

Role tasks (roles/webserver/tasks/main.yml):

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

- name: Deploy nginx config
  template:
    src: nginx.conf.j2
    dest: /etc/nginx/sites-available/default
  notify: Reload nginx

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

Use roles in a playbook:

---
- name: Configure infrastructure
  hosts: all
  become: true

  roles:
    - common
    - { role: webserver, http_port: 8080 }
    - { role: monitoring, when: enable_monitoring }

Roles are the correct abstraction level for sharing automation. A common role handles base packages, SSH keys, and NTP. A webserver role handles nginx. A database role handles PostgreSQL. Each role is testable, reusable, and self-contained.

Ansible Galaxy

Ansible Galaxy is the community hub for sharing roles and collections. Instead of writing a PostgreSQL role from scratch, install a battle-tested one:

# Install a single role
ansible-galaxy install geerlingguy.docker

# Install a collection
ansible-galaxy collection install community.docker

# Install from a requirements file
ansible-galaxy install -r requirements.yml

requirements.yml (pin versions for reproducible builds):

---
roles:
  - name: geerlingguy.docker
    version: "7.1.0"
  - name: geerlingguy.nginx
  - name: geerlingguy.postgresql
collections:
  - name: community.docker
    version: ">=3.0.0"
  - name: amazon.aws

Collections are the modern packaging format, bundling roles, modules, and plugins into a single installable unit. The community.docker collection provides docker_container and docker_compose_v2 modules. The amazon.aws collection provides EC2, S3, and RDS modules. Always pin versions and run ansible-galaxy install -r requirements.yml in CI.

Ansible Vault for Secrets

Ansible Vault encrypts sensitive data — passwords, API keys, TLS certificates — so you can safely commit them to version control.

# Create an encrypted file
ansible-vault create secrets.yml

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

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

# Decrypt (view) an encrypted file
ansible-vault view secrets.yml

# Run a playbook with vault
ansible-playbook site.yml --ask-vault-pass
ansible-playbook site.yml --vault-password-file ~/.vault_pass

Encrypt individual variables instead of entire files:

# Encrypt a single value
ansible-vault encrypt_string 'SuperSecret123' --name 'db_password'

# Output (paste this into your vars file):
db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  6531326164...

Best practice: keep encrypted vars in separate files (group_vars/production/vault.yml) alongside unencrypted vars (group_vars/production/vars.yml). This way you can see variable names without decrypting, and diffs show which files changed.

Error Handling

Ansible stops execution when a task fails. You can customize this behavior with several mechanisms:

block/rescue/always (try/catch/finally for Ansible):

- name: Deploy with rollback
  block:
    - name: Deploy new version
      copy:
        src: app-v2.tar.gz
        dest: /opt/app/app.tar.gz
    - name: Extract and restart
      shell: |
        cd /opt/app && tar xzf app.tar.gz
        systemctl restart myapp
  rescue:
    - name: Rollback to previous version
      copy:
        src: /opt/app/backup/app.tar.gz
        dest: /opt/app/app.tar.gz
    - name: Restart with old version
      service:
        name: myapp
        state: restarted
  always:
    - name: Clean up temp files
      file:
        path: /tmp/deploy-staging
        state: absent

Other error handling patterns:

# Ignore errors on a task
- name: Check optional service
  command: systemctl status optional-service
  ignore_errors: true

# Custom failure conditions
- name: Check app health
  uri:
    url: http://localhost:8080/health
  register: health
  failed_when: health.json.status != "UP"

# Custom change detection
- name: Run database migration
  command: python manage.py migrate
  register: migrate_result
  changed_when: "'No migrations to apply' not in migrate_result.stdout"

# Retry a task
- name: Wait for service to start
  uri:
    url: http://localhost:8080/health
  register: result
  until: result.status == 200
  retries: 10
  delay: 5

Real-World Playbook Examples

LAMP stack deployment:

---
- name: Deploy LAMP stack
  hosts: webservers
  become: true
  vars:
    app_db_name: myapp
    app_db_user: myapp_user
    php_version: "8.3"
  tasks:
    - name: Install Apache, PHP, and MariaDB
      apt:
        name: [apache2, "php{{ php_version }}", "php{{ php_version }}-mysql",
               mariadb-server, python3-pymysql]
        state: present
        update_cache: true

    - name: Start and enable services
      service:
        name: "{{ item }}"
        state: started
        enabled: true
      loop: [apache2, mariadb]

    - name: Create application database
      mysql_db:
        name: "{{ app_db_name }}"
        state: present
        login_unix_socket: /var/run/mysqld/mysqld.sock

    - name: Create database user
      mysql_user:
        name: "{{ app_db_user }}"
        password: "{{ vault_db_user_pass }}"
        priv: "{{ app_db_name }}.*:ALL"
        state: present
        login_unix_socket: /var/run/mysqld/mysqld.sock

    - name: Deploy application code
      synchronize:
        src: app/
        dest: /var/www/html/
      notify: Restart Apache
  handlers:
    - name: Restart Apache
      service:
        name: apache2
        state: restarted

Docker deployment:

---
- name: Deploy Docker application stack
  hosts: appservers
  become: true
  tasks:
    - name: Install Docker
      apt:
        name: [docker-ce, docker-ce-cli, containerd.io]
        state: present
        update_cache: true

    - name: Deploy application container
      docker_container:
        name: webapp
        image: "myregistry/webapp:{{ app_version | default('latest') }}"
        state: started
        restart_policy: unless-stopped
        ports: ["8080:8080"]
        env:
          DATABASE_URL: "{{ db_connection_string }}"
        networks:
          - name: app_network

    - name: Deploy Redis
      docker_container:
        name: redis
        image: redis:7-alpine
        state: started
        restart_policy: unless-stopped
        networks:
          - name: app_network

User management:

---
- name: Manage system users
  hosts: all
  become: true
  vars:
    admin_users:
      - { name: alice, key: "ssh-ed25519 AAAA...", groups: "sudo,docker" }
      - { name: bob, key: "ssh-ed25519 AAAA...", groups: "docker" }
    removed_users: [charlie, dave]

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

    - name: Set authorized keys
      authorized_key:
        user: "{{ item.name }}"
        key: "{{ item.key }}"
        exclusive: true
      loop: "{{ admin_users }}"

    - name: Remove former employees
      user:
        name: "{{ item }}"
        state: absent
        remove: true
      loop: "{{ removed_users }}"

    - name: Harden SSH config
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: "{{ item.regexp }}"
        line: "{{ item.line }}"
      loop:
        - { regexp: "^PermitRootLogin", line: "PermitRootLogin no" }
        - { regexp: "^PasswordAuthentication", line: "PasswordAuthentication no" }
      notify: Restart sshd

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

Ansible vs Terraform vs Chef vs Puppet

FeatureAnsibleTerraformChefPuppet
Primary UseConfig mgmtInfra provisioningConfig mgmtConfig mgmt
LanguageYAMLHCLRuby DSLPuppet DSL
ArchitectureAgentless (SSH)Agentless (API)Agent + ServerAgent + Server
StateStatelessState fileServer-sideServer-side
Learning CurveLowMediumHighHigh
Best ForServer config, deployCloud infraComplex policiesEnterprise compliance

The practical answer: Use Terraform to create infrastructure (VMs, networks, databases). Use Ansible to configure what runs on that infrastructure (packages, services, deployments). They complement each other. Chef and Puppet are mature tools but require dedicated infrastructure (Chef Server, PuppetDB) and custom DSLs that increase operational overhead. For teams starting fresh in 2026, Ansible + Terraform covers most automation needs.

Best Practices

Frequently Asked Questions

What is Ansible and what is it used for?

Ansible is an open-source automation tool for configuration management, application deployment, and infrastructure orchestration. It uses YAML playbooks to describe desired system state and executes tasks over SSH without requiring agents on managed nodes. It is used to provision servers, deploy applications, manage configurations, and automate repetitive IT tasks across hundreds or thousands of machines simultaneously.

What is the difference between Ansible and Terraform?

Terraform excels at provisioning infrastructure: creating cloud resources like VMs, networks, and databases using declarative HCL with state tracking. Ansible excels at configuring those resources after they exist: installing packages, deploying code, managing services. Terraform tracks resource state in a state file; Ansible is stateless and checks current state on each run. They are complementary — use Terraform to create servers, then Ansible to configure them.

How does Ansible's agentless architecture work?

Ansible connects to managed nodes over SSH (Linux) or WinRM (Windows). It copies small Python modules to the target, executes them, captures the output, and removes the temporary files. No daemon runs on managed nodes between runs. This means zero software to install on targets, no listening ports to secure, and no resource overhead. You only need Python installed on managed nodes, which is present by default on most Linux distributions.

What is the difference between ansible-playbook and the ansible command?

The ansible command runs single ad-hoc tasks, useful for one-off operations like restarting a service or checking disk space. The ansible-playbook command executes YAML playbooks containing ordered lists of tasks, handlers, variables, and roles. Use ansible for quick checks; use ansible-playbook for repeatable, version-controlled automation.

What is Ansible Galaxy and how do I use it?

Ansible Galaxy is a public repository of community-contributed roles and collections. Install roles with ansible-galaxy install geerlingguy.docker or define dependencies in a requirements.yml file. Collections bundle roles, modules, and plugins into distributable packages. Galaxy saves you from writing common automation from scratch, with popular roles for Nginx, Docker, PostgreSQL, and hundreds of other tools.

Conclusion

Ansible remains the most accessible automation tool in 2026. Its agentless architecture, YAML playbooks, and massive module library mean you can go from zero to automating your entire infrastructure in a day. The patterns in this guide — roles for organization, Vault for secrets, Galaxy for reuse, block/rescue for error handling — scale from a handful of servers to thousands.

Start with three things: write an inventory file for your servers, create a playbook that installs one package, and run it with --check first. Once you see the power of declarative configuration, you will never SSH into servers to make manual changes again.

⚙ Essential tools: Validate your YAML with our YAML Validator, format configs with the JSON to YAML Converter, and test regex patterns with Regex Tester.

Learn More

Related Resources

Terraform Complete Guide
Provision the cloud infrastructure Ansible configures
Kubernetes Complete Guide
Container orchestration for production workloads
Docker Compose Guide
Multi-container orchestration for development and production
GitHub Actions CI/CD Guide
Automate Ansible playbook runs in CI/CD pipelines
Linux Commands Cheat Sheet
Manage the servers Ansible configures