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:
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).Nonemeans 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-vvvto 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 withhost_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
rootover SSH on managed nodes. Always use an unprivileged service account (for exampleansibleordevops) with sudo rights and enable privilege escalation viabecome: truein 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:
- Environment variable
$ANSIBLE_CONFIG ./ansible.cfg(in the current working directory)~/.ansible.cfg(in the user directory)/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. Checkpip list | grep boto3inside the active virtual environment.Access Denied / AuthFailure: The IAM user lacks sufficient read rights forec2: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.ymlor.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 (
whenclauses 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
shellmodule reportschanged: trueby default, because Ansible cannot know which operations happened inside the shell command. To keep idempotency, set arguments such ascreates=/path/to/fileso the command is skipped when the target file already exists.
❗ Typical failures in ad-hoc runs:
Permission denied: The operation requires root privileges. Add--becometo 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 withansible <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.webserversorall:!db1for 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) withnotify.
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. Addupdate_cache: yesto the task.Handler was not executed: A handler only runs if the triggering task reportschanged: 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 megabytesansible_processor_vcpus: Number of available virtual CPU coresansible_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 thewebserversgroup.
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_portinstead ofhttp-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 (|), 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_varsandhost_varsfor 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.