Ansible fundamentals: automation for Linux administrators

Ansible fundamentals: agentless automation with playbooks, inventories, facts and variables for Linux admins — scalable infrastructure in practice.

Reading time: 49 min

In modern IT infrastructure, configuring individual servers by hand over interactive SSH sessions is obsolete. Anyone managing dozens or hundreds of Linux machines hits the limits of classic ad-hoc scripts quickly: missing idempotency, inconsistent package states and messy configuration drift produce unstable environments. That is where Ansible has become the industry standard for configuration management, provisioning and orchestration.

Ansible follows a strictly declarative, agentless approach. Instead of requiring its own background daemons on target systems, it relies on established standard protocols: a central control instance (control node) connects via OpenSSH to the target hosts (managed nodes) and runs short-lived Python module payloads there. All configuration states live in readable YAML files — the playbooks.

The core technical foundations of Ansible for system administrators and DevOps engineers follow: architecture and installation, static and dynamic inventories, ad-hoc commands and playbooks, then precise control through variables, facts and Jinja2 templates.

Central questions:

  • Architecture and model: Why does the agentless approach give a much faster start than Puppet or Chef?
  • Inventory: How do you build static server lists in INI and YAML format and bind them dynamically to cloud APIs (such as AWS EC2 or Azure)?
  • Ad-hoc automation: When are one-line commands useful, and how do the built-in core modules drive privileged operations in parallel?
  • Playbooks and idempotency: How do you orchestrate multi-stage software deployments with handlers, loops and error handling so they stay reproducible?
  • Dynamics and templating: How do Ansible facts capture system state at runtime, and how do Jinja2 templates render tailored configuration files?

⚠️ Prerequisites for hands-on work: To follow the practical examples you need a Linux system (for example Ubuntu 22.04 LTS or CentOS/RHEL) as the control node. On the target systems (managed nodes) an OpenSSH server and a working Python 3 runtime must be installed. Basic command-line fluency, SSH key authentication and YAML syntax are assumed.

Markers used:

💡 Practical tips, background and recommendations for efficient workflows
⚠️ Warnings about security risks, privilege pitfalls and misconfiguration
🔧 Practical implementation examples with commands and configuration files
❗ Typical sources of error, root-cause analysis and targeted fixes

Ansible basics: getting started with automation

Repeating the same administration commands by hand on several servers costs time and carries a high error risk. Ansible turns those tasks into reproducible Infrastructure as Code (IaC).

Architecture and core technical principles

Ansible was originally developed by Michael DeHaan and is now continued as an open-source project under Red Hat. It differs from traditional configuration tools in several fundamental ways:

Agentless architecture

While systems such as Puppet, Chef or SaltStack typically require proprietary agents on the target systems — including regular updates, certificate management and open listener ports — Ansible needs no dedicated software on managed nodes. A normal SSH login and an installed Python interpreter (at least version 3.5) are enough. At runtime the control node generates temporary Python scripts, transfers them via SFTP or SCP to the target, runs them in isolation and reads the result back as a structured JSON object. After the task finishes, the temporary script is removed completely from the target.

Declarative paradigm and idempotency

Classic shell scripts define how a step is to be executed (for example: apt-get install nginx). Run such a script more than once and you can get unintended side effects or errors unless you programmed explicit pre-checks. Ansible works declaratively: tasks describe the desired target state (for example: state: present). The underlying Ansible modules only act when the actual state differs from the desired state. That principle of idempotency guarantees that repeated playbook runs on already correctly configured systems make no changes (changed: false).

Push instead of pull

Classic agent systems poll a central master server at regular intervals (pull model). Ansible initiates connections actively from the control node (push model). That gives administrators full control over the exact execution time of maintenance windows, patch cycles and deployments, without waiting for polling intervals.

Use cases and typical applications

  • Configuration management: Unifying system files (/etc/ssh/sshd_config, /etc/ntp.conf, user accounts).
  • Software deployment: Rollout of web and database servers including reproducible start configurations.
  • Orchestration: Multi-tier flows where databases are migrated before web servers and load balancers are temporarily taken out of the pool.
  • Security and compliance audits: Regular checks of security-critical kernel parameters, permissions and firewall rules.

Installation methods and version check

Ansible is installed only on the control node. Managed nodes need no Ansible packages. Depending on the Linux distribution and operational requirements, several install paths are available.

🔧 Practical example: installation on Debian and Ubuntu

On Debian GNU/Linux or Ubuntu, installation uses the official package repository. First update the package sources and install the base package:


sudo apt update && sudo apt upgrade -y
sudo apt install ansible -y

For environments that always need the latest upstream version or a specific release, install via the Python package manager pip:


sudo apt install python3-pip python3-venv -y
pip3 install ansible==2.14.0

Verify a successful install and the environment configuration with the version command:


ansible --version

The output provides essential diagnostics about the runtime:


ansible [core 2.14.0]
  config file = None
  configured module search path = ['/home/user/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
  ansible python module location = /usr/lib/python3/dist-packages/ansible
  ansible collection location = ['/home/user/.ansible/collections', '/usr/share/ansible/collections']
  executable location = /usr/bin/ansible
  python version = 3.10.6 (main, May 29 2023, 11:10:38) [GCC 11.3.0]
  jinja version = 3.0.3
  libyaml = True

Explanation of the output values:

  • config file: Shows the currently active configuration file (ansible.cfg). None means defaults are in use.
  • ansible python module location: Path of Ansible's underlying Python libraries.
  • python version: Interpreter version on the control node (required: Python 3.8+ for modern Ansible Core releases).
  • jinja version: Version of the Jinja2 templating engine used for variable and template substitution.
  • libyaml: Whether the high-performance C-based YAML parser could be loaded.

Installation on RHEL, CentOS and Fedora:

On Enterprise Linux derivatives you first enable the EPEL repository (Extra Packages for Enterprise Linux) before installing Ansible with the dnf package manager:


# On RHEL / CentOS Stream
sudo dnf install epel-release -y
sudo dnf install ansible -y

# On Fedora
sudo dnf install ansible -y

Installation on macOS (as a developer control node):


brew install ansible

Isolated execution via virtual environments

To avoid version conflicts between global system Python packages and project-related Ansible modules, professional use should isolate via venv:


python3 -m venv ~/ansible-env
source ~/ansible-env/bin/activate
pip install --upgrade pip
pip install ansible

Troubleshooting the first setup

If ansible --version or basic commands fail, work through the following checks:

Check Python integrity:

Make sure python3 --version reports at least Python 3.8.

Isolate package conflicts:

When system packages and pip are used in parallel, pip3 show ansible can show which binary takes precedence in $PATH.

Missing authentication libraries:

If target systems must be managed interactively with passwords instead of SSH keys, Ansible requires the helper sshpass:


sudo apt install sshpass -y

Run a local loopback test:


ansible localhost -m ping

Core Ansible architecture

The system components follow a clearly structured division of labour. All control logic, inventories and playbooks stay central on the control node, while managed nodes are exclusively passive execution endpoints.


┌─────────────────────────────────────────────────────────────┐
│                 Ansible control architecture                │
├──────────────────────────────┬──────────────────────────────┤
│ Control Node (orchestration) │ Managed Nodes (targets)      │
├──────────────────────────────┼──────────────────────────────┤
│                              │                              │
│   ┌──────────────────────┐   │   ┌──────────────────────┐   │
│   │ Ansible Engine (CLI) │   │   │ Managed Node 1       │   │
│   │ Playbooks & YAML     │───┼──►│ Standard SSH daemon  │   │
│   │ Inventory (INI/YAML) │   │   │ Python 3 interpreter │   │
│   │ OpenSSH Client       │   │   │ No agent / daemon    │   │
│   └──────────────────────┘   │   └──────────────────────┘   │
│              │               │                              │
│              │ SSH connection│   ┌──────────────────────┐   │
│              │ (Port 22 TCP) │   │ Managed Node 2       │   │
│              └───────────────┼──►│ Linux OS / Facts     │   │
│                              │   │ Idempotent modules   │   │
│                              │   └──────────────────────┘   │
│                              │                              │
└──────────────────────────────┴──────────────────────────────┘

First steps: establish a connection

To contact target systems Ansible needs an inventory file that defines host addresses and connection parameters.

Create a basic inventory file named inventory.ini:


[webservers]
web1 ansible_host=192.168.1.10 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa
web2 ansible_host=192.168.1.11 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa

[dbservers]
db1 ansible_host=192.168.1.20 ansible_user=ubuntu ansible_ssh_private_key_file=~/.ssh/id_rsa

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

Generate and distribute an SSH key pair:

If you do not yet have a dedicated SSH key for automation, generate a modern Ed25519 or RSA key pair on the control node and place the public key on the target hosts:


ssh-keygen -t rsa -b 4096 -C "ansible-control@admindocs.local"
ssh-copy-id ubuntu@192.168.1.10
ssh-copy-id ubuntu@192.168.1.11
ssh-copy-id ubuntu@192.168.1.20

Connection test via the ping module:

Check reachability and the Python subsystem on all defined nodes with the ad-hoc command ping:


ansible all -i inventory.ini -m ping -u ubuntu

On successful communication each node answers with a standardised JSON result:


web1 | SUCCESS => {
    "ansible_facts": {
        "discovered_interpreter_python": "/usr/bin/python3"
    },
    "changed": false,
    "ping": "pong"
}

Typical first-setup failures:

  • UNREACHABLE: The target host does not answer on port 22, or the given SSH key was rejected. Run the command with -vvv to inspect the detailed SSH handshake.
  • MODULE FAILURE: /usr/bin/python3: not found: Python is not installed on the target. Install a minimal Python on the target (sudo apt install python3-minimal).
  • Host key verification failed: The target host fingerprint is not yet registered in ~/.ssh/known_hosts. For test environments this can be controlled with host_key_checking = False.

Tool comparison in the infrastructure landscape

Which configuration tool you pick depends mainly on infrastructure size, team know-how and security requirements:

Tool Architecture Configuration language Scaling model Learning curve Primary use Operational challenges
Ansible Agentless (SSH/WinRM) YAML / Jinja2 Push-based Flat Linux automation, multi-cloud deployments Sequential SSH runtimes at thousands of hosts
Puppet Master / Agent Puppet DSL / Ruby Pull-based (30 min) Moderate Enterprise compliance, rigid configuration management Master infrastructure, certificate management
Chef Server / Agent Ruby DSL Pull-based Steep Complex cloud application infrastructures High programming effort, version upkeep
SaltStack Master / Minion (ZeroMQ) YAML / Python Push & event-driven Moderate Very large clusters with real-time needs Own daemons and open firewall ports
Shell scripts Manual / SSH wrapper Bash / POSIX Sh Linear / imperative Low Fast local ad-hoc tasks No idempotency, little error handling

⚠️ Security note for SSH access: Never configure direct administrative login as root over SSH on managed nodes. Always use an unprivileged service account (for example ansible or devops) with sudo rights and enable privilege escalation via become: true in the playbook.

Configuration fundamentals and base settings

Ansible behaviour can be tuned in detail via the central file ansible.cfg. Ansible searches several directory paths in a fixed order:

  1. Environment variable $ANSIBLE_CONFIG
  2. ./ansible.cfg (in the current working directory)
  3. ~/.ansible.cfg (in the user directory)
  4. /etc/ansible/ansible.cfg (system-wide configuration)

Create a tailored ansible.cfg in the project directory:


[defaults]
inventory = ./inventory.ini
remote_user = ubuntu
host_key_checking = False
forks = 10
timeout = 30
log_path = ./ansible.log

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

First administrative package installation:

With that configuration in place, a short command is enough to install Nginx on the webservers group:


ansible webservers -m apt -a "name=nginx state=present" --become

Inventories and hosts

The inventory is the heart of every Ansible environment. It defines not only hostnames and IP addresses, but also logical groups, environment stages and variable structures.

Static inventories in INI format

The INI format has become the primary standard for many Linux administrators because of its simplicity and clarity.

Here is a complete example of a web and database stack with nested groups:


# Web server group with individual connection parameters
[webservers]
web1 ansible_host=192.168.1.10 ansible_user=devops ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_become=true ansible_become_method=sudo
web2 ansible_host=192.168.1.11 ansible_user=devops ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_become=true ansible_become_method=sudo

# Database group with system-specific variables
[dbservers]
db1 ansible_host=192.168.1.20 ansible_user=devops ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_become=true
db2 ansible_host=192.168.1.21 ansible_user=devops ansible_ssh_private_key_file=~/.ssh/id_rsa ansible_become=true

# Meta group for the production environment
[production:children]
webservers
dbservers

# Global variables for all hosts
[all:vars]
ansible_python_interpreter=/usr/bin/python3
ntp_server=ntp.example.com
log_level=info

# Group-specific variables for web servers
[webservers:vars]
http_port=80
max_clients=200
web_server=nginx

# Group-specific variables for database servers
[dbservers:vars]
db_engine=mysql
db_port=3306
backup_frequency=daily

Host check and variable inspection:


# Test reachability of the web servers
ansible webservers -m ping

# Read variables set on a group
ansible webservers -m debug -a "var=http_port"

Host variables and IP ranges

Individual hosts can override values from group variables:


[webservers]
web1 ansible_host=192.168.1.10 max_clients=300
web2 ansible_host=192.168.1.11

For homogeneous server farms with sequential numbering, the INI format offers practical range notation:


[webservers]
web[01:10].example.com ansible_host=192.168.1.[10:19]

Hierarchical structures in YAML format

For highly structured or nested environments, YAML has the advantage of representing native data types (lists, booleans, dictionaries) exactly.

Create inventory.yaml with clean 2-space indentation:


all:
  vars:
    ansible_python_interpreter: /usr/bin/python3
    ntp_server: ntp.example.com
    log_level: info
  children:
    webservers:
      hosts:
        web1:
          ansible_host: 192.168.1.10
          ansible_user: devops
          ansible_become: true
          ansible_become_method: sudo
          max_clients: 300
        web2:
          ansible_host: 192.168.1.11
          ansible_user: devops
          ansible_become: true
          ansible_become_method: sudo
      vars:
        http_port: 80
        web_server: nginx
    dbservers:
      hosts:
        db1:
          ansible_host: 192.168.1.20
          ansible_user: devops
          ansible_become: true
        db2:
          ansible_host: 192.168.1.21
          ansible_user: devops
          ansible_become: true
      vars:
        db_engine: mysql
        db_port: 3306
        backup_frequency: daily
    production:
      children:
        webservers:
        dbservers:
      vars:
        environment: prod
        monitoring_enabled: true
    staging:
      children:
        webservers:
      vars:
        environment: staging
        monitoring_enabled: false

Addressing works analogously to the INI file:


ansible -i inventory.yaml production -m ping

💡 Practical tip for host organisation: Use short, functional host aliases (such as web-prod-01) instead of cryptic cloud hostnames (ec2-198-51-100-24.compute-1.amazonaws.com). That makes logs, error output and playbook reports much easier to read.

Dynamic inventories for cloud environments

In dynamic cloud and container environments (such as AWS, Azure or GCP) virtual instances are continuously created or terminated. Static inventory files would go stale. Dynamic inventory plugins fill that gap.


┌─────────────────────────────────────────────────────────────┐
│             Inventory sources and host resolution           │
├──────────────────────────────┬──────────────────────────────┤
│ Static definition            │ Dynamic cloud plugins        │
├──────────────────────────────┼──────────────────────────────┤
│                              │                              │
│   ┌──────────────────────┐   │   ┌──────────────────────┐   │
│   │ INI / YAML Inventory │   │   │ Cloud API (AWS/GCP)  │   │
│   │ [webservers]         │   │   │ Instance metadata    │   │
│   │ Host variables       │   │   │ Tags and instance IDs│   │
│   └──────────┬───────────┘   │   └──────────┬───────────┘   │
│              │               │              │               │
│              ▼               │              ▼               │
│   ┌──────────────────────┐   │   ┌──────────────────────┐   │
│   │ Fixed host assignment│   │   │ Dynamic mapping      │   │
│   │ Local IP addresses   │   │   │ Autoscaling groups   │   │
│   └──────────┬───────────┘   │   └──────────┬───────────┘   │
│              │               │              │               │
├──────────────┴───────────────┴──────────────┴───────────────┤
│                              ▼                              │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Ansible parser: aggregated host and group matrix    │   │
│   └─────────────────────────────────────────────────────┘   │
└─────────────────────────────────────────────────────────────┘

🔧 Practical example: AWS EC2 dynamic inventory

First install the required Python SDK packages on the control node:


pip install boto3 botocore

Create the plugin configuration file aws_ec2.yaml:


plugin: amazon.aws.aws_ec2
regions:
  - eu-central-1
  - us-east-1
keyed_groups:
  - key: tags.Role
    prefix: role
  - key: tags.Environment
    prefix: env
hostnames:
  - private-ip-address
  - tag:Name
compose:
  ansible_host: private_ip_address

Make sure AWS credentials are available via environment variables or ~/.aws/credentials:


export AWS_ACCESS_KEY_ID="AKIAIOSFODNN7EXAMPLE"
export AWS_SECRET_ACCESS_KEY="wJalrXUtnFEMI/K7MDENG/bPxRfiCYEXAMPLEKEY"

Test the dynamic query with the inventory inspection command:


ansible-inventory -i aws_ec2.yaml --graph

The output shows host groups generated automatically from AWS tags:


@all:
  |--@aws_ec2:
  |  |--10.0.1.45
  |  |--10.0.1.88
  |--@env_production:
  |  |--10.0.1.45
  |--@role_webservers:
  |  |--10.0.1.45

The dynamically discovered cloud instances can now be addressed directly via their tags:


ansible -i aws_ec2.yaml role_webservers -m ping

Proven practices for professional inventories:

Criterion Recommended practice Operational benefit Practical example
Logical groups Split by function and stage Targeted playbook execution [webservers], [dbservers], [prod:children]
Group variables Define shared parameters centrally Fewer redundancies and typos [webservers:vars] http_port=80
Directory layout Split inventories into sub-files Clear split between environments inventories/production/, inventories/staging/
Security isolation Encrypt passwords and tokens Protection of confidential credentials ansible-vault create group_vars/all/vault.yml
Host validation Check syntax before running Avoid runtime aborts ansible-inventory -i inventory.ini --list

Troubleshooting dynamic inventories:

  • Missing dependency: boto3: The AWS SDK is missing from Ansible's Python path. Check pip list | grep boto3 inside the active virtual environment.
  • Access Denied / AuthFailure: The IAM user lacks sufficient read rights for ec2:DescribeInstances. Attach a policy with read access.
  • Inventory parse error: Wrong file extension for plugins. Modern Ansible cloud plugins strictly require extensions such as .aws_ec2.yml or .azure_rm.yml.

⚠️ Security note on IP addresses and cloud metadata: In cloud environments prefer internal private IP addresses and reach managed nodes via a VPN or an SSH bastion host (jump host). Never expose SSH ports (TCP 22) unprotected to the public internet.

Ad-hoc commands

Ad-hoc commands are one-line execution commands that give administrators immediate access to the entire fleet. They are excellent for fast status queries, emergency patches or one-off maintenance.

Syntax and parallel command execution

The basic anatomy of an ad-hoc command is:


ansible <host-pattern> -m <module_name> -a "<module_arguments>" [options]

Ansible runs these commands highly in parallel. The -f (forks) parameter sets how many parallel SSH connections are opened at once:


ansible webservers -m ping -f 10

┌─────────────────────────────────────────────────────────────┐
│           Ad-hoc execution flow over SSH forks              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ ansible <group> -m <module> -a "<arguments>"        │   │
│   └──────────────────────────┬──────────────────────────┘   │
│                              │                              │
│                              ▼                              │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Parse inventory and host pattern (e.g. webservers)  │   │
│   └──────────────────────────┬──────────────────────────┘   │
│                              │                              │
│                 ┌────────────┴────────────┐                 │
│                 ▼                         ▼                 │
│   ┌──────────────────────────┐ ┌──────────────────────────┐ │
│   │ Fork 1: SSH to Node 1    │ │ Fork 2: SSH to Node 2    │ │
│   │ Transfer module payload  │ │ Transfer module payload  │ │
│   │ Python execution         │ │ Python execution         │ │
│   └─────────────┬────────────┘ └─────────────┬────────────┘ │
│                 │                            │              │
│                 └────────────┬───────────────┘              │
│                              ▼                              │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ JSON result aggregation (SUCCESS / CHANGED / FAIL)  │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Important core modules in operational use

1. command vs. shell: understanding command execution

The command module runs commands directly on the target. It bypasses a shell (/bin/sh), so shell special characters such as pipes (|), redirections (>, <) and environment variables are not interpreted. That makes the module especially safe against unintended code injection:


ansible all -m command -a "/usr/bin/uptime"

The shell module, by contrast, starts a full shell on the target. It allows pipes, chaining and wildcards, but should be used with care:


ansible webservers -m shell -a "uptime | awk '{print $3}' > /tmp/uptime.log" --become

2. Package management: apt, yum and the universal package module

Ansible offers distribution-specific modules as well as the abstract package module, which automatically detects the native package manager on the target (APT, DNF, Pacman):


# Debian/Ubuntu specific with cache refresh
ansible webservers -m apt -a "name=nginx state=present update_cache=yes cache_valid_time=3600" --become

# RHEL/CentOS specific
ansible dbservers -m yum -a "name=mariadb-server state=present" --become

# Distribution-independent (universal)
ansible all -m package -a "name=htop state=present" --become

3. User and key management

User accounts can be set up idempotently including group membership, shell and home directory:


ansible dbservers -m user -a "name=appuser uid=1050 state=present shell=/bin/bash groups=wheel append=yes" --become

Place the public SSH key for the newly created user:


ansible dbservers -m authorized_key -a "user=appuser state=present key='{{ lookup('file', '~/.ssh/id_rsa.pub') }}'" --become

4. File transfer and status check

The copy module transfers files from the control node to the targets. With backup=yes Ansible automatically takes a safety backup of the destination file before overwriting:


ansible webservers -m copy -a "src=/local/configs/nginx.conf dest=/etc/nginx/nginx.conf owner=root group=root mode=0644 backup=yes" --become

Check existence, permissions and checksums of existing files with stat:


ansible webservers -m stat -a "path=/etc/nginx/nginx.conf get_checksum=yes"

5. Service management and system reboot

Services (systemd) are controlled via the service module:


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

# Enable a service (boot autostart) and start it
ansible dbservers -m service -a "name=mariadb state=started enabled=yes" --become

A controlled reboot of entire server groups can be handled with the specialised reboot module, which automatically waits until the machine answers on port 22 again:


ansible webservers -m reboot -a "msg='Scheduled maintenance by AdminDocs' reboot_timeout=600" --become

Module overview table

The following table summarises the most important ad-hoc modules:

Module Main function Idempotent Typical key arguments Operational scenario
ping Connectivity and Python check Yes None Connection diagnosis
command Direct command execution No cmd, chdir, creates, removes Standard commands without shell features
shell Execution with shell features No cmd, executable, creates Pipelines, redirects, environment variables
package Universal package management Yes name, state=present/absent Mixed environments (Debian & RHEL)
apt / yum Specific package management Yes name, state, update_cache Exact package care with cache parameters
copy File transfer (local to remote) Yes src, dest, owner, mode, backup Provisioning of configurations
stat Metadata and hash check Yes path, get_checksum Pre-checks in scripts
service systemd service control Yes name, state=started/stopped, enabled Daemon lifecycle and autostart
user User and group management Yes name, uid, groups, state, shell Standardising service accounts
setup Collect system facts Yes filter, gather_subset Hardware and OS inventory

Limits of ad-hoc commands

Ad-hoc commands stop where complex workflows begin. They offer:

  • No event control (handlers for services after file changes)
  • No conditional execution logic (when clauses across several steps)
  • No integrated rollback and rescue mechanisms (block / rescue)
  • No structured versionability in Git repositories

As soon as several tasks must run in a defined order and be documented, you need structured playbooks.

⚠️ Warning on the shell module: The shell module reports changed: true by default, because Ansible cannot know which operations happened inside the shell command. To keep idempotency, set arguments such as creates=/path/to/file so the command is skipped when the target file already exists.

Typical failures in ad-hoc runs:

  • Permission denied: The operation requires root privileges. Add --become to the command.
  • Missing arguments: Module arguments must be passed as a single string to -a (e.g. -a "name=nginx state=present").
  • No hosts matched: The host pattern matches no machine in the inventory. Check with ansible <pattern> --list-hosts.

Playbooks: structured automation

Playbooks are the heart of reproducible automation. Structured YAML documents describe the desired state of entire infrastructures, version it and apply it step by step.

Structure and basic elements of a playbook

A playbook consists of one or more “plays”. Each play assigns an ordered list of tasks to a target group of hosts.


┌─────────────────────────────────────────────────────────────┐
│            Architecture of a structured play                │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Play: target group (hosts), privilege escalate      │   │
│   └──────────────────────────┬──────────────────────────┘   │
│                              │                              │
│                 ┌────────────┴────────────┐                 │
│                 ▼                         ▼                 │
│   ┌──────────────────────────┐ ┌──────────────────────────┐ │
│   │ Variables and scope      │ │ Pre-tasks and facts      │ │
│   │ vars: and vars_files:    │ │ gather_facts: true       │ │
│   └─────────────┬────────────┘ └─────────────┬────────────┘ │
│                 │                            │              │
│                 └────────────┬───────────────┘              │
│                              ▼                              │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Tasks (sequential module runs: apt, copy...)        │   │
│   │ - name: task description / when: / loop:            │   │
│   └──────────────────────────┬──────────────────────────┘   │
│                              │                              │
│                 ┌────────────┴────────────┐                 │
│                 ▼ Status: changed         ▼ Error occurred  │
│   ┌──────────────────────────┐ ┌──────────────────────────┐ │
│   │ Handlers (notified)      │ │ Rescue block (fallback)  │ │
│   │ Service restart / reload │ │ Rollback and error analysis│
│   └──────────────────────────┘ └──────────────────────────┘ │
│                                                             │
└─────────────────────────────────────────────────────────────┘

🔧 Practical example: complete web server playbook

Create the file setup_webserver.yaml:


---
- name: Set up and harden the Nginx web server
  hosts: webservers
  become: true
  vars:
    http_port: 80
    web_package: nginx
    server_admin: admin@admindocs.local

  tasks:
    - name: Update package cache on Debian systems
      apt:
        update_cache: yes
        cache_valid_time: 3600
      when: ansible_os_family == 'Debian'

    - name: Install the Nginx package
      package:
        name: "{{ web_package }}"
        state: present

    - name: Deploy a tailored configuration file
      copy:
        src: ./files/nginx.conf
        dest: /etc/nginx/nginx.conf
        owner: root
        group: root
        mode: '0644'
        backup: yes
      notify: Reload Nginx

    - name: Ensure Nginx is started and registered for autostart
      service:
        name: "{{ web_package }}"
        state: started
        enabled: true

  handlers:
    - name: Reload Nginx
      service:
        name: "{{ web_package }}"
        state: reloaded

Execution and syntax check:

Before a real run on the production network, every playbook should be validated:


# 1. Run a syntax check
ansible-playbook setup_webserver.yaml --syntax-check

# 2. Dry run with diff display
ansible-playbook setup_webserver.yaml --check --diff

# 3. Actual execution
ansible-playbook setup_webserver.yaml

Core components of the playbook at a glance:

  • hosts: Determines the target group from the inventory (e.g. webservers or all:!db1 for exclusions).
  • become: Enables privilege escalation via sudo for the entire play.
  • vars: Local variables referenced inside the tasks.
  • tasks: The sequential list of actions to run.
  • handlers: Reactive tasks that only run at the end of the play if a watching task reported a state change (changed: true) with notify.

Control structures: loops, conditionals and handlers

1. Iterations with loops

Instead of copying the same task several times for different packages or users, loop bundles the execution:


- name: Install required system packages
  package:
    name: "{{ item }}"
    state: present
  loop:
    - curl
    - htop
    - rsync
    - ufw

2. Conditions with when directives

Tasks can be bound specifically to operating systems, kernel versions or custom flags:


- name: Open firewall port on RedHat systems
  firewalld:
    port: "{{ http_port }}/tcp"
    permanent: true
    state: enabled
  when: ansible_os_family == 'RedHat'

- name: Open firewall port on Debian/Ubuntu via UFW
  ufw:
    rule: allow
    port: "{{ http_port }}"
    proto: tcp
  when: ansible_os_family == 'Debian'

🔧 Practical example: web server deployment with firewall and validation

This more advanced playbook shows installation, firewall hardening, dynamic index creation and a subsequent function check via the uri module working together:


---
- name: Enterprise web stack rollout with verification
  hosts: webservers
  become: true
  vars:
    http_port: 80
    app_title: "AdminDocs Production Server"

  tasks:
    - name: Install Nginx and UFW
      package:
        name:
          - nginx
          - ufw
        state: present
      register: pkg_result

    - name: Allow HTTP port in the firewall
      ufw:
        rule: allow
        port: "{{ http_port }}"
        proto: tcp
      when: pkg_result.changed

    - name: Enable the UFW service
      ufw:
        state: enabled

    - name: Serve a static landing page
      copy:
        content: "<h1>{{ app_title }}</h1><p>Managed by Ansible.</p>"
        dest: /var/www/html/index.html
        owner: www-data
        group: www-data
        mode: '0644'

    - name: Run a function check via HTTP request
      uri:
        url: "http://localhost:{{ http_port }}"
        return_content: yes
        status_code: 200
      register: web_response
      failed_when: "'AdminDocs Production' not in web_response.content"

    - name: Print a success message in the log
      debug:
        msg: "Web server verified successfully. HTTP status: {{ web_response.status }}"

Error handling, blocks and debugging

Complex deployments need fail-safety. With block, rescue and always, Ansible offers structured error handling analogous to try-catch blocks:


---
- name: Robust user configuration with recovery
  hosts: all
  become: true
  vars:
    required_users:
      - name: devuser
        group: developers
      - name: audituser
        group: auditor

  tasks:
    - name: Primary execution block
      block:
        - name: Create the developers group
          group:
            name: developers
            state: present

        - name: Initialise user accounts
          user:
            name: "{{ item.name }}"
            groups: "{{ item.group }}"
            state: present
          loop: "{{ required_users }}"

      rescue:
        - name: Capture error diagnosis
          debug:
            msg: "Error creating user accounts on {{ inventory_hostname }}. Starting rollback..."

        - name: Run security logging
          shell: "logger -t ansible 'User provisioning failed on host'"

      always:
        - name: Run a closing check
          debug:
            msg: "User provisioning run finished."

Diagnosis and troubleshooting in playbook operation:

Syntax check:


ansible-playbook site.yaml --syntax-check

Start execution at a specific task:


ansible-playbook site.yaml --start-at-task="Allow HTTP port in the firewall"

Interactive step-by-step mode:


ansible-playbook site.yaml --step

Host restriction at runtime:


ansible-playbook site.yaml --limit web1

Show the task list without executing:


ansible-playbook site.yaml --list-tasks
Best practice Operational implementation Benefit
Descriptive task names name: Enable Nginx service in systemd Meaningful logs and CLI output
Use handlers notify: Reload service Prevents unnecessary restarts when files are unchanged
Dry run before rollout --check --diff Visualises configuration drift before the change
Version control Keep playbooks strictly in Git Traceability and team collaboration
Modular structure Move tasks into tasks/main.yml High reuse across projects

⚠️ Warning about YAML indentation errors: In YAML, tab characters (\t) are forbidden as indentation. Use spaces only (recommended: 2 spaces per level). A single tab causes an immediate parse abort (YAML syntax error).

Typical failures in playbook runs:

  • fatal: [web1]: FAILED! => {"changed": false, "msg": "No package matching 'nginx' found"}: The local package cache is stale. Add update_cache: yes to the task.
  • Handler was not executed: A handler only runs if the triggering task reports changed: true. If the file was not modified, the handler stays inactive.
  • Variable is undefined: A variable referenced in the playbook exists neither in the play nor in inventory or host files. Set safe fallbacks with {{ var | default('value') }}.

Variables and facts

Variables and facts give playbooks the flexibility they need. Instead of hard-coding configuration values, dynamic playbooks adapt to environment stages, operating-system families and hardware resources.

Definition and scopes of variables

In Ansible, variables can be defined at several levels:

1. Inline in the playbook


---
- name: Demonstration of inline variables
  hosts: all
  vars:
    app_name: core-service
    service_port: 8080
    allowed_ips:
      - 10.0.0.1
      - 10.0.0.2
    database:
      name: production_db
      pool_size: 25
  tasks:
    - name: Print configuration values
      debug:
        msg: "Service {{ app_name }} listens on port {{ service_port }} with DB {{ database.name }}"

2. External variable files (vars_files)

For larger setups, variables are organised in dedicated files under vars/:


# vars/app_settings.yaml
app_name: core-service
http_port: 80
environment: production
database_driver: mysql
max_connections: 500

Inclusion in the playbook:


- name: Playbook with external variables
  hosts: webservers
  vars_files:
    - vars/app_settings.yaml
  tasks:
    - name: Apply the web port
      debug:
        msg: "Active port: {{ http_port }}"

3. Directory-based variables: group_vars and host_vars

Ansible automatically loads variable files from the group_vars/ and host_vars/ directories when they sit relative to the inventory file or the playbook:


inventories/production/
├── hosts.ini
├── group_vars/
│   ├── all.yaml          # Applies to all nodes
│   ├── webservers.yaml   # Applies to group webservers
│   └── dbservers.yaml    # Applies to group dbservers
└── host_vars/
    └── web1.yaml         # Overrides values exclusively for host web1

Variable precedence and priority rules

Because variables can be defined in many places, Ansible has a strict ranking (precedence). A value at a higher level overwrites identical variable names at lower levels.

The most important levels at a glance (sorted from low to high):

Rank Definition level Typical use Overwritten by
1 (Lowest) role defaults (defaults/main.yml) Default values in reusable roles Practically every other definition
2 inventory group_vars/* Base values for whole server groups Host variables, playbook vars
3 inventory host_vars/* Host-specific deviations (IPs, disks) Playbook vars
4 playbook vars (in the play header) Global settings for the play Task vars, extra vars
5 playbook vars_files External configuration files Task vars, extra vars
6 host facts (collected automatically) Hardware, network and OS data Task vars, extra vars
7 task vars (defined in the task) Valid only for that single task Extra vars
8 (Highest) extra vars (-e "key=val") Manual overrides on the command line Not overwritable by anything

🔧 Practical example: overrides on the command line


ansible-playbook deploy.yaml -e "environment=staging http_port=8080"

System analysis with Ansible facts

Facts are system information that Ansible gathers automatically from managed nodes at the start of each play via the setup module.


┌─────────────────────────────────────────────────────────────┐
│           Variable resolution and fact aggregation          │
├──────────────────────────────┬──────────────────────────────┤
│ Variable sources             │ Target metadata (facts)      │
├──────────────────────────────┼──────────────────────────────┤
│                              │                              │
│   ┌──────────────────────┐   │   ┌──────────────────────┐   │
│   │ Inventory & Groups   │   │   │ ansible_distribution │   │
│   │ vars_files & Vault   │   │   │ ansible_memtotal_mb  │   │
│   │ Extra Vars (-e)      │   │   │ ansible_default_ipv4 │   │
│   └──────────┬───────────┘   │   └──────────┬───────────┘   │
│              │               │              │               │
│              └───────────────┼──────────────┘               │
│                              ▼                              │
├─────────────────────────────────────────────────────────────┤
│                                                             │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Jinja2 template engine (evaluation and filtering)   │   │
│   │ {{ variable }} / {% if fact > limit %}              │   │
│   └──────────────────────────┬──────────────────────────┘   │
│                              │                              │
│                              ▼                              │
│   ┌─────────────────────────────────────────────────────┐   │
│   │ Rendered target configuration on the managed node   │   │
│   └─────────────────────────────────────────────────────┘   │
│                                                             │
└─────────────────────────────────────────────────────────────┘

Frequently used standard facts:

  • ansible_os_family: Operating-system family (Debian, RedHat, Archlinux)
  • ansible_distribution: Exact distribution (Ubuntu, Debian, CentOS, Fedora)
  • ansible_distribution_version: Distribution version (e.g. 22.04)
  • ansible_memtotal_mb: Total physical RAM in megabytes
  • ansible_processor_vcpus: Number of available virtual CPU cores
  • ansible_default_ipv4.address: Primary IPv4 address of the target host

🔧 Practical example: fact-driven installation

The following playbook adapts package names and memory settings dynamically to the target system:


---
- name: Intelligent configuration based on system facts
  hosts: all
  become: true
  gather_facts: true

  tasks:
    - name: Choose the web server package depending on OS family
      package:
        name: "{{ 'apache2' if ansible_os_family == 'Debian' else 'httpd' }}"
        state: present

    - name: Print system data in the log
      debug:
        msg: >
          Host: {{ ansible_hostname }} |
          OS: {{ ansible_distribution }} {{ ansible_distribution_version }} |
          RAM: {{ ansible_memtotal_mb }} MB |
          CPUs: {{ ansible_processor_vcpus }}

    - name: Adapt PHP memory limit to available RAM
      lineinfile:
        path: /etc/app.conf
        line: "php_memory_limit = {{ (ansible_memtotal_mb * 0.25) | int }}M"
        create: yes
      when: ansible_memtotal_mb > 2048

Provide your own facts (custom facts)

In addition to standard facts, administrators can place static or dynamic custom facts on target systems. They must live in /etc/ansible/facts.d/ with the .fact file extension:


sudo mkdir -p /etc/ansible/facts.d
sudo tee /etc/ansible/facts.d/datacenter.fact << 'EOF'
[location]
rack = R42
datacenter = FRA1
environment = production
EOF

Ansible reads these values automatically into the ansible_local structure:


- name: Query a custom fact
  debug:
    msg: "This server is in datacentre {{ ansible_local.datacenter.location.datacenter }}"

Fact caching for performant large environments

In environments with hundreds of servers, fetching facts at the start of every playbook can take noticeable time. Fact caching in ansible.cfg stores the metadata locally:


[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_fact_cache
fact_caching_timeout = 86400

Configuration templating with Jinja2

While simple files are transferred with copy, the template module dynamically generates configuration files via the Jinja2 templating engine.

Create the template file templates/nginx_vhost.conf.j2:


# Generated automatically by Ansible - manual changes will be overwritten
server {
    listen {{ http_port }};
    server_name {{ ansible_fqdn }};

    root /var/www/html;
    index index.html;

    # Dynamic thread calculation based on vCPUs
    worker_processes {{ ansible_processor_vcpus }};

{% if ansible_memtotal_mb > 4096 %}
    # High-performance memory cache
    fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=APP:100m inactive=60m;
{% else %}
    # Standard memory settings
    fastcgi_cache_path /var/cache/nginx levels=1:2 keys_zone=APP:20m inactive=30m;
{% endif %}

    location / {
        try_files $uri $uri/ =404;
    }
}

Provisioning in the playbook:


- name: Generate Nginx vhost from a Jinja2 template
  template:
    src: templates/nginx_vhost.conf.j2
    dest: /etc/nginx/sites-available/default
    owner: root
    group: root
    mode: '0644'
  notify: Reload Nginx

Useful Jinja2 filters and magic variables

  • default: Sets a fallback if the variable is unset:

``yaml {{ app_port | default(8080) }} ``

  • join: Joins list elements into a string:

``yaml {{ allowed_hosts | join(', ') }} ``

  • upper / lower: Converts strings to upper or lower case.
  • inventory_hostname: Contains the name of the current target host as defined in the inventory.
  • groups['webservers']: Returns a list of all hostnames in the webservers group.

Protecting sensitive data with Ansible Vault

Passwords, API tokens, SSH private keys and certificates must never be stored in plaintext in repositories. Ansible Vault encrypts files or individual variable values symmetrically (AES-256).

🔧 Practical example: managing an encrypted variable file


# Create a new encrypted file (prompts for a password)
ansible-vault create vars/vault.yaml

# Encrypt an existing plaintext file after the fact
ansible-vault encrypt vars/secrets.yaml

# Edit an encrypted file in the editor
ansible-vault edit vars/vault.yaml

# Decrypt an encrypted file for viewing
ansible-vault view vars/vault.yaml

Contents of vars/vault.yaml:


vault_db_password: "SuperSecretProductionDatabasePassword42!"
vault_api_key: "k8s-secret-token-xyz-987"

Inclusion in the playbook:


---
- name: Playbook with protected credentials
  hosts: dbservers
  become: true
  vars_files:
    - vars/vault.yaml
  tasks:
    - name: Configure the database user with a Vault password
      mysql_user:
        name: dbadmin
        password: "{{ vault_db_password }}"
        priv: "*.*:ALL"
        state: present

Running a playbook with Vault protection:


# Prompt for the password interactively
ansible-playbook deploy.yaml --ask-vault-pass

# Read the password from a protected file
ansible-playbook deploy.yaml --vault-password-file ~/.vault_pass

Typical failures with variables and facts:

  • The field 'vars' has an invalid value: Variable names must not contain hyphens (-); they must use underscores (_) (e.g. http_port instead of http-port).
  • AnsibleUndefinedVariable: Jinja2 parser error because a variable does not exist. Always catch optional variables with | default().
  • Vault password incorrect: The wrong Vault password was entered at run time, or the file was encrypted with a different Vault ID label.

⚠️ Security note on unencrypted credentials: Never store passwords or private key files in plaintext in version-control systems (Git). Set up pre-commit hooks so that unencrypted Vault files cannot be checked in by accident.

Command Reference (Cheatsheet)

The following table summarises the essential commands and options for day-to-day administrative use of Ansible:

Category Command / syntax Important options Purpose / description
Connectivity ansible <pattern> -m ping -i <inv>, -u <user> Check connection and Python subsystem on targets
Facts & hardware ansible <pattern> -m setup -a "filter=ansible_*" Collect all system metadata (OS, IP, CPU, RAM)
Command execution ansible <pattern> -m command -a "<cmd>" -f <forks>, --become Safe command execution without shell interpolation
Shell & pipelines ansible <pattern> -m shell -a "<cmd>" creates=/path Command execution with pipes (&#124;), redirects and wildcards
Package management ansible <pattern> -m package -a "name=<pkg> state=present" --become, --check Idempotent package install via the native package manager
Service control ansible <pattern> -m service -a "name=<svc> state=started" enabled=yes, --become Start, stop, reload daemons and enable autostart
File transfer ansible <pattern> -m copy -a "src=<src> dest=<dst>" mode=0644, backup=yes Provision files with an automatic backup copy
User management ansible <pattern> -m user -a "name=<usr> state=present" groups=wheel, append=yes Manage local user accounts and group assignments
Playbook syntax ansible-playbook <playbook.yaml> --syntax-check None Validate YAML structure and task syntax before execution
Dry run ansible-playbook <playbook.yaml> --check --diff -vvv Simulate planned changes and show diffs
Target limiting ansible-playbook <playbook.yaml> --limit <host> --start-at-task="<name>" Restrict execution to specific hosts or tasks
Extra variables ansible-playbook <playbook.yaml> -e "<k>=<v>" -e "@vars.json" Override variables at runtime with highest priority
Inventory graph ansible-inventory -i <inv> --graph --vars Hierarchical tree view of all groups and hosts
Vault creation ansible-vault create <secret.yaml> None Create a new AES-256-encrypted variable file
Vault encryption ansible-vault encrypt <file.yaml> ansible-vault decrypt Encrypt or decrypt existing files after the fact
Vault execution ansible-playbook site.yaml --ask-vault-pass --vault-password-file Run a playbook with password-protected Vault files

Further Resources

Deeper information, specifications and community modules are in the following official sources:

Resource Description Link
Official Ansible documentation Complete reference handbook, install guides and release notes docs.ansible.com
Ansible Getting Started Guide Step-by-step introduction to the core concepts Ansible Getting Started
Inventory Management Handbook Detailed documentation on static INI/YAML inventories and variable patterns Ansible Inventory Guide
Ansible Module & Collections Index Full directory of all built-in modules with arguments and examples Ansible Module Index
Playbook Architecture Guide Guide to tasks, handlers, loops, blocks and error handling Ansible Playbook Intro
Ansible Vault Handbook Best practices for securely encrypting confidential credentials Ansible Vault Guide
Ansible Galaxy Official community hub for reusable roles and collections galaxy.ansible.com
Ansible GitHub Repository Source code, issue tracker and technical discussions of the open-source project github.com/ansible/ansible

Conclusion

Ansible closes the gap between manual system administration and highly complex enterprise orchestration tools. By consistently skipping target-system agents and using existing security standards such as OpenSSH, it offers an exceptionally fast start into Infrastructure as Code.

Ansible’s strength is predictability: declarative playbooks and the strict principle of idempotency keep server configurations traceable, versionable and free of creeping drift. With inventories, ad-hoc commands, robust playbook structures and precise variable control through facts and Jinja2 templates, Linux administrators have a solid foundation for production operation.

💡 Practical tip for production: Do not start with huge, monolithic playbooks. Split recurring administration tasks into modular units from the beginning. Use group_vars and host_vars for environment-specific parameters and protect sensitive credentials exclusively with Ansible Vault. Anyone who internalises that structured approach can scale from a handful of test servers to hundreds of production instances.

The next step is Ansible Roles and Collections, so playbooks can be structured into standardised directory trees with predefined defaults, templates and tasks. Combined with CI/CD pipelines (such as GitLab CI or GitHub Actions), Ansible becomes the central engine of a fully automated modern delivery chain.

Share & export

Export as Markdown

Related posts