惯性聚合 高效追踪和阅读你感兴趣的博客、新闻、科技资讯
阅读原文 在惯性聚合中打开

推荐订阅源

S
Schneier on Security
L
LangChain Blog
博客园 - Franky
Microsoft Security Blog
Microsoft Security Blog
M
MIT News - Artificial intelligence
月光博客
月光博客
云风的 BLOG
云风的 BLOG
MongoDB | Blog
MongoDB | Blog
量子位
AWS News Blog
AWS News Blog
Jina AI
Jina AI
Webroot Blog
Webroot Blog
L
Lohrmann on Cybersecurity
Cisco Talos Blog
Cisco Talos Blog
Latest news
Latest news
Y
Y Combinator Blog
The GitHub Blog
The GitHub Blog
NISL@THU
NISL@THU
The Register - Security
The Register - Security
美团技术团队
博客园 - 三生石上(FineUI控件)
I
Intezer
钛媒体:引领未来商业与生活新知
钛媒体:引领未来商业与生活新知
N
Netflix TechBlog - Medium
H
Hackread – Cybersecurity News, Data Breaches, AI and More
T
The Exploit Database - CXSecurity.com
C
Cisco Blogs
Attack and Defense Labs
Attack and Defense Labs
S
Securelist
Know Your Adversary
Know Your Adversary
MyScale Blog
MyScale Blog
C
CERT Recently Published Vulnerability Notes
D
Darknet – Hacking Tools, Hacker News & Cyber Security
U
Unit 42
cs.AI updates on arXiv.org
cs.AI updates on arXiv.org
雷峰网
雷峰网
B
Blog
P
Privacy International News Feed
W
WeLiveSecurity
T
Threatpost
P
Palo Alto Networks Blog
O
OpenAI News
cs.CV updates on arXiv.org
cs.CV updates on arXiv.org
博客园_首页
Exploit-DB.com RSS Feed
Exploit-DB.com RSS Feed
Forbes - Security
Forbes - Security
K
Kaspersky official blog
Recent Announcements
Recent Announcements
A
About on SuperTechFans
B
Blog RSS Feed

Devoriales - DevOps and Python Tutorials

Cloud & DevOps & AI Digest: The Week of Jun 28, 2026 Cloud & DevOps & AI Digest: The Week of Jun 20, 2026 Login Must-Have Kubernetes CLI Tools Every Platform Engineer Should Know Login Login Login Why Your Best Engineers Are Quitting (And How to Stop It) Login ArgoCD Vulnerability: How the ServerSideDiff Feature Exposes Kubernetes Secrets Login How Kubernetes Controls What Your Containers Can Do Login Multi-AZ Is Not Disaster Recovery: What the AWS Bahrain Outage Finally Proved Trivy Supply Chain Attack: When Your Security Scanner Becomes the Threat Is Claude Opus 4.6 Fast Mode Really Worth 6× the Price? Login Unlocking Higher Pod Density in EKS with Prefix Delegation AWS Regional NAT Gateway: What It Is and Why You Should Care Kubernetes 1.35 Timbernetes Release AWS re:Invent 2025: The Future of Kubernetes on EKS Debate Series: How Do We Control Deployment Order in Kubernetes? Debate Series: Should We Eliminate Kubernetes Secrets Entirely? Kubernetes CRDs Explained: A Beginner-Friendly Guide to Extending the Kubernetes API Reduce Cloud Cross-Zone Data Transfer Costs with Kubernetes 1.33 trafficDistribution Building Custom Bitnami Images: A Guide for Self-Hosted Container Images New Features in Kubernetes 1.34: An Overview From Free to Fee: How Broadcom's Bitnami Monetization Disrupts DevOps Infrastructure Claude Code Cheat Sheet: The Reference Guide Kubernetes Loses Enterprise Slack Status: Discord Among Platforms Being Considered Understanding Container Security: A Guide to Docker and Pod Security Container Patterns in Kubernetes: Init Containers, Sidecars, and Co-located Containers Explained AWS Launches Serverless MCP Server: AI-Powered Development Gets a Serverless Boost Valve Responds to Alleged Steam Data Breach Reports: What Users Need to Know ArgoCD 3.0: The Evolution Toward Secure GitOps Redis Returns to Open Source: The AGPLv3 Licensing Decision New Features in Kubernetes 1.33: An Overview Prometheus: How We Slashed Memory Usage IngressNightmare: Critical Ingress-NGINX Vulnerabilities and How to Check Your Exposure New Features in Kubernetes 1.32: An Overview What to Consider If You're Not Signing Up for Bitnami Premium Certified Kubernetes Administrator (CKA) Exam Updates for 2025 DeepSeek AI and the Question of the AI Bubble Python Tops the Tiobe Index: The Most Popular Programming Languages - January 2025 2024 in Review: IT Trends, Startups, and What’s Next Inside Argo: The Open-Source Journey Captured in a CNCF Documentary Running Docker on macOS Without Docker Desktop - updated with Kubernetes installation HashiCorp Rolls Out Terraform 2.0 at HashiConf, Keeps IBM Acquisition in the Shadows Is the EU Falling Behind in the Global AI Race? Prometheus Essentials: Node Exporter And System Monitoring Prometheus Essentials: Install and Start Monitoring Your App Prometheus Essentials: Introduction To Metric Types Kubernetes Pod Scheduling Explained: Taints, Tolerations, and Node Affinity Retrieval Augmented Generation (RAG) Explained for Beginners Like Me Using Sealed Secrets with Your Kubernetes Applications
Ansible for DevOps Engineers: Architecture, Core Concepts, and Hands-On Lab
Aleksandro Matejic · 2026-06-20 · via Devoriales - DevOps and Python Tutorials

Manually logging into servers to install packages, push configuration files, or restart services works fine when you have two machines. It breaks down completely when you have twenty — or two hundred. A missed step on one node means your fleet drifts out of sync, and you won't find out until a production incident exposes the inconsistency at 2 AM.

Ansible is the tool that eliminates this class of problem. You describe exactly what every server should look like in plain YAML and Ansible applies that description across your entire fleet in seconds — from your laptop, over SSH, with no software installed on the servers themselves.

This article covers the architecture, each core component, and the hands-on lab setup that the Devoriales Ansible course uses (three Docker containers as managed nodes on your Mac).


What is Ansible?

Ansible is an open-source IT automation platform originally created by Michael DeHaan and acquired by Red Hat in 2015. It is designed to automate configuration management, application deployment, cloud provisioning, and orchestration across a large number of systems simultaneously.

What distinguishes Ansible from other automation tools is its agentless, push-based architecture. There is no daemon to install on managed servers. Ansible connects over SSH (or WinRM for Windows), executes temporary scripts on the remote system, collects the results, and reports back — all from your control node. Once Ansible finishes, nothing persists on the managed node except the changes you asked for.

Automation in Ansible is described in YAML files called playbooks. YAML is human-readable and deliberately simple, which means your playbooks serve as both automation scripts and living documentation of your infrastructure's desired state. A developer who has never used Ansible before can read a well-written playbook and understand what it does without learning a new programming language.

Ansible is also idempotent. Run the same playbook ten times and only the first run makes changes — subsequent runs detect that the desired state is already in place and do nothing. This makes re-running automation safe and predictable, which is critical for CI/CD pipelines and disaster recovery.


Key Features and Benefits

Agentless Architecture

Ansible requires no software installation on managed nodes. It uses SSH — a protocol present on every Linux and macOS server and many network devices — to connect, push a temporary Python script, execute it, read the output as JSON, and remove the script. The only prerequisites on a managed node are:

  • An open SSH port
  • Python 3 installed (standard on Ubuntu 20.04+, RHEL 8+, and most modern Linux distributions)

This design dramatically reduces operational overhead. There is no agent version to maintain, no daemon to monitor, no port to open beyond SSH, and no bootstrapping problem to solve before you can manage a new server.

YAML-Based Playbooks

Playbooks are the language of Ansible. They use YAML syntax to declare:

  • Which hosts or groups of hosts to target
  • In what order to run tasks
  • Which modules to call and with what arguments
  • When to escalate to root (become: true)
  • What variables to use

A playbook is readable by anyone on your team, which means it also serves as runbook documentation. When a new engineer joins and needs to understand how your nginx configuration is deployed, they read the playbook — not tribal knowledge in a Confluence page.

Idempotency and Predictability

Ansible tasks are designed to check the current state of the system before making changes. The package module checks whether a package is already installed before calling apt or yum. The copy module computes a checksum of the remote file before overwriting it. The user module checks whether a user already exists before creating one.

The result is that the same playbook can run safely in development, staging, and production — in CI pipelines, scheduled jobs, or on-demand — without fear of inadvertent changes.

Extensible via Collections and Modules

Ansible ships with hundreds of built-in modules for managing packages, files, services, users, cloud resources, network devices, and databases. The community extends this further with collections — bundles of modules, plugins, roles, and playbooks organized by vendor or domain (e.g., community.docker, amazon.aws, kubernetes.core). You install collections with ansible-galaxy collection install.

Cross-Platform Support

Ansible manages Linux (Red Hat, Debian, SUSE, and derivatives), Windows (via WinRM and PowerShell), macOS, network devices (Cisco IOS, Junos, Arista EOS), and cloud APIs. The same control node manages all of them. A single playbook can configure Linux web servers and Windows Active Directory in the same run.


Ansible Architecture: Core Components

Understanding Ansible's architecture helps you write better playbooks, debug problems faster, and design automation that scales.

Control Node

The control node is the machine where Ansible is installed and from which all automation is driven. In our lab, this is your Mac. In production, it is typically a bastion host, a CI/CD runner, or a dedicated automation server.

The control node: - Reads your inventory to know which hosts exist and how they are grouped - Parses playbooks to determine what tasks to run - Compiles Python modules and pushes them to managed nodes over SSH - Collects return values (JSON) from managed nodes and builds the play output - Evaluates whether each task resulted in a change, failure, or no-op

Ansible does not require a central server for most use cases. The control node is sufficient. (Ansible Tower / AWX adds a web UI, RBAC, and scheduling on top, but that is a separate product.)

Managed Nodes

Managed nodes are the systems Ansible controls. In our lab these are Docker containers. In production they are virtual machines, bare-metal servers, cloud instances, or network devices.

Managed nodes need: - SSH running and accessible from the control node - Python 3 installed (for most Linux modules) - A user account the control node can SSH into, with sudo access if tasks require root

No Ansible software is installed. When a task runs, Ansible transfers a temporary Python script to /tmp on the managed node, executes it via SSH, reads stdout (JSON), and deletes the script. The managed node is stateless from Ansible's perspective.

Ansible push-based architecture — the control node (your Mac) pushes tasks over SSH to managed nodes. No agent runs on the servers.

Inventory

The inventory is a file (or set of files, or a dynamic source) that tells Ansible which managed nodes exist and how they are grouped. Groups allow you to target subsets of your fleet — for example, run a task only against [webservers] or only against [dbservers].

A simple INI inventory looks like this:

[webservers]
web1 ansible_host=localhost ansible_port=2201
web2 ansible_host=localhost ansible_port=2202

[dbservers]
db1 ansible_host=localhost ansible_port=2203

Every inventory automatically contains two implicit groups: - all — every host in the inventory - ungrouped — hosts that are not in any named group

You can also define a dynamic inventory — a script or plugin that queries an external source (AWS EC2, Azure, GCP, Kubernetes) and returns the current list of hosts at runtime. This is essential for cloud environments where hosts are ephemeral and IP addresses change constantly.

Playbooks

A playbook is the primary unit of Ansible automation. It is a YAML file containing one or more plays. Each play specifies:

  • hosts — which inventory group(s) to target
  • become — whether to escalate to root
  • vars — play-level variables
  • tasks — the ordered list of tasks to execute

A task calls a module with specific arguments. Tasks execute sequentially within a play, but multiple plays in a playbook execute in order, and multiple hosts within a play execute in parallel (up to the forks limit, default 5).

---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Install nginx
      package:
        name: nginx
        state: present

    - name: Copy virtual host configuration
      copy:
        content: "server { listen 80; server_name web1; }"
        dest: /etc/nginx/conf.d/app.conf
      notify: Reload nginx

  handlers:
    - name: Reload nginx
      command: nginx -s reload

Modules

Modules are the workers of Ansible. Each task calls exactly one module. A module is a self-contained Python script (or PowerShell script for Windows) that:

  1. Receives task arguments as JSON
  2. Checks the current state of the system
  3. Makes changes only if needed (idempotency)
  4. Returns a JSON result including changed, failed, and any relevant output

Ansible ships with hundreds of modules. Here are some of the most commonly used:

Module What it does
package / apt / yum Install, upgrade, or remove packages
copy Copy a file from control node to managed node
template Render a Jinja2 template and copy to managed node
file Manage file and directory attributes (permissions, ownership, state)
service Start, stop, enable, or disable a system service
user Create, modify, or delete user accounts
command / shell Run arbitrary commands (not idempotent by default)
setup Gather facts about the managed node
git Clone or update a Git repository
uri Make HTTP requests

Modules are invoked with key-value arguments:

- name: Ensure the ansible user exists
  user:
    name: ansible
    shell: /bin/bash
    state: present

You can also run a module ad hoc (without a playbook) using the ansible command:

ansible webservers -m package -a "name=curl state=present" --become

Handlers

Handlers are special tasks that only run when notified by another task that reported a change. They execute at the end of the play, after all regular tasks have run — and only once, no matter how many tasks notify the same handler.

This is the standard pattern for service reloads:

tasks:
  - name: Deploy nginx config
    copy:
      content: "server { listen {{ app_port }}; }"
      dest: /etc/nginx/conf.d/app.conf
    notify: Reload nginx     # queues the handler only if this task changed

handlers:
  - name: Reload nginx
    command: nginx -s reload

If the config file is already correct (task reports ok), the handler is never queued and nginx is never restarted. If ten tasks all notify the same handler, it still only fires once. This prevents unnecessary service restarts.

Variables and Facts

Variables let you parameterize playbooks so the same automation logic works across different environments, hosts, and configurations.

Defined variables are values you set explicitly:

# group_vars/webservers.yml
app_port: 8080
app_name: meridian-web

Ansible auto-loads group_vars/<groupname>.yml for every host in that group before any play runs. Host-specific overrides go in host_vars/<hostname>.yml.

Facts are variables Ansible discovers automatically by running the setup module at the start of every play (gather_facts: true is the default). Facts include:

Fact Example value
ansible_hostname web1
ansible_distribution Ubuntu
ansible_distribution_version 22.04
ansible_memtotal_mb 3940
ansible_default_ipv4.address 172.17.0.2
ansible_processor_vcpus 2

Facts are available anywhere variables are used — in task arguments, templates, conditionals, and variable files.

Variable precedence in Ansible (lowest to highest):

Source Precedence
Role defaults (defaults/main.yml) lowest
Inventory group variables
Inventory host variables
group_vars/ files
host_vars/ files
Play vars: section
vars_files:
Extra vars (-e on CLI) highest

When a variable has an unexpected value, trace up this list to find which source is winning.

Roles

Roles are the packaging mechanism for Ansible automation. A role is a directory with a predefined structure that groups related tasks, handlers, variables, templates, and files into a single reusable unit.

Create a role skeleton with:

ansible-galaxy role init roles/webserver

This generates:

roles/webserver/
├── tasks/
│   └── main.yml      ← entry point; Ansible loads this automatically
├── handlers/
│   └── main.yml
├── defaults/
│   └── main.yml      ← lowest-precedence variable defaults
├── vars/
│   └── main.yml      ← higher-precedence role variables
├── files/            ← static files for the copy module
├── templates/        ← Jinja2 templates for the template module
└── meta/
    └── main.yml      ← role metadata and dependencies

A playbook that uses the role:

---
- name: Configure web servers
  hosts: webservers
  become: true
  roles:
    - webserver

Roles make automation reusable. The same webserver role can be applied in dev, staging, and production playbooks. Teams share roles via Ansible Galaxy (ansible-galaxy role install).

Plugins

Plugins extend how Ansible itself operates — not what it does to managed nodes, but how it connects, formats output, looks up data, and processes logic. Plugins run on the control node.

Key plugin types:

Plugin type What it does
Connection (ssh, docker, local) How Ansible connects to managed nodes
Callback (default, json, yaml) How playbook output is formatted and displayed
Inventory (aws_ec2, azure_rm) How dynamic inventory is sourced
Lookup (file, env, vault) How external data is retrieved into variables
Filter (to_json, select, map) Jinja2 filters for variable transformation
Action Local pre/post processing around module execution

Most users never write a plugin. But understanding they exist explains why you can write {{ lookup('env', 'DB_PASSWORD') }} in a playbook and have Ansible read an environment variable, or why you can change your output format with -o json or a callback plugin configuration.


Setting Up Your Ansible Lab on macOS

Rather than working on a remote server, we will build a local lab using Docker containers as managed nodes. This gives you a production-realistic environment you can break and rebuild freely in minutes.

Our lab will have: - 1 control node — your Mac - 3 managed nodes — Docker containers named web1, web2, and db1 - Ansible connecting over SSH to ports 2201, 2202, and 2203

Prerequisites

  • macOS (Apple Silicon M1/M2/M3 or Intel)
  • Docker Desktop for Mac installed and running
  • Terminal (zsh or bash)

Step 1: Install Ansible

brew install ansible

As of mid-2026 this installs Ansible 14.0.0 (which bundles ansible-core 2.21.0). Verify:

ansible --version

You will see the core version, the Python interpreter Ansible uses, and the config file location (shows None until you create one — we do that in Step 6).

Two version numbers to know: - ansible-core — the execution engine (ansible-playbook, ansible, ansible-galaxy) - ansible (the community package) — ansible-core plus a curated set of collections. When you brew install ansible, you get both.

Step 2: Generate an SSH Keypair for the Lab

Ansible authenticates over SSH using keys — more secure and automation-friendly than passwords:

ssh-keygen -t ed25519 -f ~/.ssh/ansible_lab -N "" -C "ansible-lab"

This creates two files: - ~/.ssh/ansible_lab — the private key (stays on your machine, never shared) - ~/.ssh/ansible_lab.pub — the public key (distributed to managed nodes)

The -N "" flag sets an empty passphrase so Ansible can connect non-interactively. In production you would use SSH agent forwarding or a passphrase-protected key with ssh-agent.

Step 3: Build the Managed Node Docker Image

Create a Dockerfile that produces a minimal Ubuntu 22.04 container with SSH and Python — the only two prerequisites Ansible needs:

mkdir -p ~/ansible-lab

cat > ~/ansible-lab/Dockerfile <<'EOF'
FROM ubuntu:22.04

ENV DEBIAN_FRONTEND=noninteractive

RUN apt-get update && apt-get install -y \
    openssh-server \
    python3 \
    python3-apt \
    curl \
    sudo \
    && rm -rf /var/lib/apt/lists/*

RUN useradd -m -s /bin/bash ansible && \
    echo 'ansible ALL=(ALL) NOPASSWD:ALL' >> /etc/sudoers && \
    mkdir -p /home/ansible/.ssh && \
    chmod 700 /home/ansible/.ssh && \
    chown ansible:ansible /home/ansible/.ssh

RUN mkdir -p /var/run/sshd

EXPOSE 22
CMD ["/usr/sbin/sshd", "-D"]
EOF

docker build -t ansible-node ~/ansible-lab/

What this image contains: - openssh-server — so Ansible can connect via SSH - python3 — required for Ansible to execute modules - python3-apt — required for Ansible's apt and package modules on Debian/Ubuntu - An ansible OS user with passwordless sudo — Ansible will SSH in as this user - /var/run/sshd — the directory the SSH daemon needs at startup

Step 4: Start Three Managed Nodes

# Remove any existing lab containers
docker rm -f web1 web2 db1 2>/dev/null || true

# Start fresh
docker run -d --name web1 --hostname web1 -p 2201:22 ansible-node
docker run -d --name web2 --hostname web2 -p 2202:22 ansible-node
docker run -d --name db1  --hostname db1  -p 2203:22 ansible-node

The --hostname flag is important. Without it, Docker assigns the container ID as the system hostname (a long hex string). Setting it explicitly means ansible_hostname (a fact) returns web1, web2, or db1 — matching your inventory names.

Verify all three are running:

docker ps --filter name=web1 --filter name=web2 --filter name=db1

You should get a result like this:

CONTAINER ID   IMAGE          COMMAND               CREATED         STATUS         PORTS                                     NAMES
e9a95c4cf3d5   ansible-node   "/usr/sbin/sshd -D"   7 seconds ago   Up 7 seconds   0.0.0.0:2203->22/tcp, [::]:2203->22/tcp   db1
01eebe0a7dba   ansible-node   "/usr/sbin/sshd -D"   8 seconds ago   Up 7 seconds   0.0.0.0:2202->22/tcp, [::]:2202->22/tcp   web2
e526adaf7693   ansible-node   "/usr/sbin/sshd -D"   8 seconds ago   Up 8 seconds   0.0.0.0:2201->22/tcp, [::]:2201->22/tcp   web1

Copy your public key into each container so Ansible can authenticate without a password:

for port in 2201 2202 2203; do
  docker exec $(docker ps -q --filter publish=$port) \
    bash -c "echo '$(cat ~/.ssh/ansible_lab.pub)' >> /home/ansible/.ssh/authorized_keys && \
    chmod 600 /home/ansible/.ssh/authorized_keys && \
    chown -R ansible:ansible /home/ansible/.ssh"
done

Test the SSH connection manually to confirm it works:

ssh -i ~/.ssh/ansible_lab -p 2201 -o StrictHostKeyChecking=no ansible@localhost "hostname"

Expected output: web1. Repeat for ports 2202 and 2203 to verify all three nodes.

Step 6: Configure Ansible

Create the lab workspace and configuration:

mkdir -p ~/ansible-lab/lab/inventory

cat > ~/ansible-lab/lab/ansible.cfg <<'EOF'
[defaults]
inventory         = ./inventory/hosts.ini
remote_user       = ansible
private_key_file  = ~/.ssh/ansible_lab
host_key_checking = False
interpreter_python = /usr/bin/python3

[privilege_escalation]
become        = False
become_method = sudo
EOF

ansible.cfg is read from the current directory first, then ~/.ansible.cfg, then /etc/ansible/ansible.cfg. Keeping it in the project directory means your settings are project-scoped.

Create the inventory file:

cat > ~/ansible-lab/lab/inventory/hosts.ini <<'EOF'
[webservers]
web1 ansible_host=localhost ansible_port=2201
web2 ansible_host=localhost ansible_port=2202

[dbservers]
db1 ansible_host=localhost ansible_port=2203
EOF

Step 7: Verify the Lab

cd ~/ansible-lab/lab && ansible all -m ping

Expected output:

web1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
web2 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}
db1 | SUCCESS => {
    "changed": false,
    "ping": "pong"
}

Three pong responses mean SSH authentication, Python execution, and module communication are all working.


Core Concepts in Practice

Inventory: Inspecting What Ansible Sees

Run this to see how Ansible resolves your inventory:

cd ~/ansible-lab/lab && ansible-inventory --graph

Output:

@all:
  |--@ungrouped:
  |--@webservers:
  |  |--web1
  |  |--web2
  |--@dbservers:
  |  |--db1

You can also inspect a specific host's variables:

ansible-inventory --host web1

This shows all variables Ansible knows about web1 — including the connection parameters from your inventory and any facts (if gathered).

Ad Hoc Commands: Running Tasks Without a Playbook

Ad hoc commands let you run a single module directly against hosts without writing a playbook. They are useful for quick checks, one-time operations, and troubleshooting.

The syntax is:

ansible <host-pattern> -m <module> -a "<arguments>" [options]

Check the OS on all nodes using the setup module (which collects facts):

cd ~/ansible-lab/lab && ansible all -m setup -a "filter=ansible_distribution*"

This returns ansible_distribution (Ubuntu), ansible_distribution_version (22.04), and related facts for each node.

Run a shell command on all web servers:

cd ~/ansible-lab/lab && ansible webservers -m command -a "uptime"

Install a package on web servers (with --become to run as root):

cd ~/ansible-lab/lab && ansible webservers -m package -a "name=curl state=present" --become

Run this command twice. The first run reports changed (yellow) — curl was installed. The second reports ok (green) — curl is already present, nothing to do. The changed vs ok distinction is how Ansible tells you whether it actually modified the system.

Your First Playbook

Playbooks let you combine multiple tasks, target multiple groups, and express dependencies between operations.

cat > ~/ansible-lab/lab/site.yml <<'EOF'
---
- name: Configure web servers
  hosts: webservers
  become: true

  tasks:
    - name: Install curl
      package:
        name: curl
        state: present

    - name: Write a marker file
      copy:
        content: "Managed by Ansible on {{ ansible_hostname }}\n"
        dest: /tmp/ansible-marker.txt

- name: Configure database servers
  hosts: dbservers
  become: true

  tasks:
    - name: Install curl
      package:
        name: curl
        state: present

    - name: Write a db marker file
      copy:
        content: "DB node {{ ansible_hostname }} managed by Ansible\n"
        dest: /tmp/ansible-marker.txt
EOF

Run it:

cd ~/ansible-lab/lab && ansible-playbook site.yml

The PLAY RECAP at the bottom summarises ok, changed, unreachable, and failed counts per host. Run the playbook a second time — every task shows ok because the desired state is already in place.

Read the marker file from each node:

cd ~/ansible-lab/lab && ansible all -m command -a "cat /tmp/ansible-marker.txt"

Each node returns its own hostname in the content — because {{ ansible_hostname }} was resolved per-host when the copy task ran.

Variables and group_vars

Hardcoding values in playbooks creates maintenance problems. Variables let you define values once and use them everywhere.

Create group variables for the web tier:

mkdir -p ~/ansible-lab/lab/group_vars

cat > ~/ansible-lab/lab/group_vars/webservers.yml <<'EOF'
app_port: 8080
app_name: meridian-web
EOF

cat > ~/ansible-lab/lab/group_vars/dbservers.yml <<'EOF'
db_port: 5432
db_name: meridian_db
EOF

Now write a playbook that uses these variables:

cat > ~/ansible-lab/lab/node-info.yml <<'EOF'
---
- name: Write node information to each host
  hosts: all
  become: true

  tasks:
    - name: Write node info file
      copy:
        content: |
          Hostname:   {{ ansible_hostname }}
          OS:         {{ ansible_distribution }} {{ ansible_distribution_version }}
          App port:   {{ app_port | default('N/A') }}
          DB port:    {{ db_port | default('N/A') }}
        dest: /tmp/node_info.txt
EOF

cd ~/ansible-lab/lab && ansible-playbook node-info.yml

Then read the results:

cd ~/ansible-lab/lab && ansible all -m command -a "cat /tmp/node_info.txt"

Web servers show App port: 8080 from group_vars/webservers.yml. The database server shows DB port: 5432. Neither shows the other group's variable. This is group variable scoping working correctly — variables defined for a group are only available to hosts in that group.

The | default('N/A') filter handles hosts where a variable is not defined, preventing an "undefined variable" error.

Handlers: Reacting to Change

Handlers solve a specific and common problem: you only want to restart a service if its configuration actually changed, not on every playbook run.

cat > ~/ansible-lab/lab/handlers-demo.yml <<'EOF'
---
- name: Demonstrate handlers
  hosts: webservers
  become: true

  handlers:
    - name: Reload nginx config
      command: echo "nginx -s reload would run here"

  tasks:
    - name: Write nginx config
      copy:
        content: "# nginx config for {{ app_name }} on port {{ app_port }}\n"
        dest: /tmp/nginx.conf
      notify: Reload nginx config
EOF

cd ~/ansible-lab/lab && ansible-playbook handlers-demo.yml

Run it once — the copy task reports changed (the file is new), so the handler fires. Run it a second time — copy reports ok (file content matches), so the handler never runs.

Key handler behaviours: - A handler is only queued when its notifying task reports changed - If multiple tasks notify the same handler, it still runs exactly once - Handlers run after all regular tasks in the play complete — not immediately when notified - If a play fails before handlers run, the handlers are skipped (use --force-handlers to override)

Ansible handler notification flow: task runs, if state changed handler is queued, fires exactly once at end of play
Handler notification flow — a handler is queued only when a task reports changed, and fires once at the end of the play regardless of how many tasks notified it.

Roles: Organising Automation

As playbooks grow, roles keep things manageable. A role groups tasks, handlers, variables, templates, and files into a single reusable directory structure.

cd ~/ansible-lab/lab && ansible-galaxy role init roles/webserver

Write tasks for the role:

cat > ~/ansible-lab/lab/roles/webserver/tasks/main.yml <<'EOF'
---
- name: Write web server configuration
  copy:
    content: |
      # {{ app_name }} configuration
      Port: {{ app_port }}
    dest: /tmp/webserver.conf
  notify: Reload web config

- name: Write uptime to status file
  command: uptime
  register: uptime_result
  changed_when: false

- name: Write status file
  copy:
    content: "{{ uptime_result.stdout }}\n"
    dest: /tmp/webserver-status.txt
EOF

cat > ~/ansible-lab/lab/roles/webserver/handlers/main.yml <<'EOF'
---
- name: Reload web config
  command: echo "Reloading web config..."
EOF

cat > ~/ansible-lab/lab/roles/webserver/defaults/main.yml <<'EOF'
---
app_port: 80
app_name: default-app
EOF

Use the role in a playbook:

cat > ~/ansible-lab/lab/deploy.yml <<'EOF'
---
- name: Deploy web tier
  hosts: webservers
  become: true
  roles:
    - webserver
EOF

cd ~/ansible-lab/lab && ansible-playbook deploy.yml

The defaults/main.yml defines the lowest-priority default for app_port (80), but group_vars/webservers.yml defines app_port: 8080 at a higher precedence — so the group variable wins.

changed_when: false on the uptime command tells Ansible this command never constitutes a change (it's read-only), preventing it from being counted as a modification.


Ansible Modules vs. Module Utilities

The distinction between modules and module utilities is worth understanding if you ever write custom automation or need to debug deep Ansible errors.

Modules are the units you call from tasks. Each module performs one discrete action: install a package, copy a file, create a user. Modules are self-contained — you specify one in a task and Ansible runs it.

Module utilities (in ansible/module_utils/) are shared Python libraries that modules import internally. They handle repetitive low-level logic — HTTP request handling, JSON argument parsing, file permission checks — so individual modules don't duplicate this code.

  Modules Module Utilities
What it does Performs a task on a managed node Provides shared helper functions to modules
Called from Your playbook tasks Inside module source code
You interact with it Directly (in task definitions) Rarely (only when writing custom modules)
Examples package, copy, user, service basic.py, urls.py, common/file.py
Language Any that outputs JSON Python or PowerShell

When Ansible transfers a module to a managed node, it also transfers any module utilities that module depends on. This is bundled transparently — you never see it unless you're writing a custom module.


Ansible Modules vs. Plugins

Modules and plugins are both written in Python, but they solve different problems and run in different places.

Modules run on the managed node. They implement the "what to do" logic — install this package, copy this file, create this user. Modules are transferred to the remote system, executed, and their JSON output is read back by the control node.

Plugins run on the control node. They extend how Ansible itself behaves — how it connects to hosts, how it formats output, how it looks up data, how it handles inventory.

  Modules Plugins
Where it runs Managed node Control node
What it does Performs a task on the remote system Extends Ansible's core behaviour
Called from Task definitions Configured in ansible.cfg or used implicitly
Language Any language outputting JSON Python only
Examples copy, package, git, uri ssh (connection), default (callback), aws_ec2 (inventory)

A concrete example: when you run ansible-playbook site.yml -o json, you're using the json callback plugin — it changes how playbook output is formatted. That has nothing to do with what happens on managed nodes. But when a task runs copy:, the copy module is transferred to each managed node and executed there.


Ansible ships as a suite of CLI tools. Each serves a specific purpose:

ansible — Ad Hoc Commands

Run a single module against one or more hosts without writing a playbook:

# Ping all hosts
ansible all -m ping

# Run a shell command on web servers
ansible webservers -m command -a "df -h /"

# Install a package
ansible all -m package -a "name=htop state=present" --become

# Gather facts about a specific host
ansible web1 -m setup

ansible-playbook — Run Playbooks

The primary way to run automation:

# Basic run
ansible-playbook site.yml

# Target a specific host or group (overrides hosts: in the playbook)
ansible-playbook site.yml --limit web1

# Dry run — shows what would change without applying anything
ansible-playbook site.yml --check

# Dry run + show diff of file changes
ansible-playbook site.yml --check --diff

# Override a variable at runtime
ansible-playbook site.yml -e "app_port=9090"

# Use a different inventory
ansible-playbook site.yml -i inventories/prod/hosts.yml

# Verbose output (use -vv or -vvv for more detail)
ansible-playbook site.yml -v

The --check and --diff flags are essential before applying changes to production. They connect to real hosts and evaluate tasks against the real current state, but apply nothing.

ansible-inventory — Inspect Inventory

# View inventory as a tree
ansible-inventory --graph

# View all variables for a specific host
ansible-inventory --host web1

# Export inventory as JSON
ansible-inventory --list

ansible-galaxy — Manage Roles and Collections

# Install a role from Ansible Galaxy
ansible-galaxy role install geerlingguy.nginx

# Initialize a new role skeleton
ansible-galaxy role init roles/myapp

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

# List installed collections
ansible-galaxy collection list

ansible-vault — Manage Encrypted Secrets

# Create a new encrypted file
ansible-vault create vault.yml

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

# View encrypted file contents
ansible-vault view vault.yml

# Edit encrypted file
ansible-vault edit vault.yml

# Decrypt a file permanently
ansible-vault decrypt vault.yml

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

ansible-doc — Offline Module Documentation

# View full documentation for a module
ansible-doc copy

# List all available modules
ansible-doc -l

# Show a short description of a module
ansible-doc -s user

ansible-doc is invaluable. It shows every argument a module accepts, their types, required/optional status, and examples — all offline without opening a browser.

ansible-lint — Static Analysis

ansible-lint is a separate tool (install with pip3 install ansible-lint) that analyses your playbooks and roles for best-practice violations without connecting to any host:

# Lint the entire project
ansible-lint

# Lint a specific playbook
ansible-lint site.yml

# List all available rules
ansible-lint --list-rules

Common violations ansible-lint catches: - Tasks without a name: field - Using command: when a dedicated module exists (e.g., command: git pull instead of the git module) - YAML values like yes/no instead of true/false - Role names containing dashes (breaks collection namespacing) - Missing changed_when on command/shell tasks


Production Patterns: What Comes Next

Production-scale Ansible builds on these fundamentals with several additional layers:

Multi-environment inventories — A inventories/ directory with separate dev/, staging/, and prod/ subdirectories. Each environment has its own hosts.yml and group_vars/. Running ansible-playbook site.yml targets dev by default. Targeting prod requires an explicit -i inventories/prod/hosts.yml. Dev changes structurally cannot reach prod.

Ansible Vault — AES-256 encryption for secrets directly in your repository. Database passwords, API tokens, and private keys live in encrypted vault.yml files that are committed to Git (the ciphertext is safe to store). The vault decryption password lives outside Git, distributed via a team password manager. The vault indirection patternvault.yml holds the encrypted secret, vars.yml references it by variable name — means plaintext variable names appear in search results without ever exposing values.

Per-environment vault IDs — Dev has one vault password (all developers know it). Prod has a different vault password (only senior engineers or CI systems know it). When a developer leaves, you rotate only the passwords for environments they accessed. Per-developer SSH key revocation works the same way — remove one public key file, re-run bootstrap.

Git hygiene.gitignore blocks vault password files (*.vault_pass, .vault_pass). Pre-commit hooks via detect-secrets and ansible-vault-encrypted reject any commit that includes a plaintext secret or an unencrypted vault file.

The verification workflow — Before any production deployment:

# 1. Static analysis — no hosts needed
ansible-lint

# 2. Syntax check — no hosts needed
ansible-playbook site.yml --syntax-check

# 3. Dry run against dev
ansible-playbook -i inventories/dev/hosts.yml site.yml --check --diff

# 4. Real run against dev
ansible-playbook -i inventories/dev/hosts.yml site.yml

# 5. Dry run against prod (with peer review)
ansible-playbook -i inventories/prod/hosts.yml site.yml --check --diff

# 6. Real run against prod
ansible-playbook -i inventories/prod/hosts.yml site.yml

Steps 1 and 2 are cheap — run them in CI on every pull request. Steps 3-6 require real hosts.


Take It Further on Devoriales

The Devoriales Ansible course covers everything in this article interactively — real terminal in the browser, verification checks after every step, quizzes after every lesson:

  • 7 fundamentals lessons — control node setup, inventory, modules, playbooks, variables, handlers, roles
  • 9 best-practices lessons — multi-environment inventories, vault, vault IDs, team SSH workflow, project structure, git hygiene, ansible-lint, check mode, the 5-developer team workflow
  • 2 Killercoda labs — full interactive scenarios in a real Linux environment

Enroll in the Ansible course on Devoriales →