

Last updated: April 2026
An Ansible playbook is a YAML file that defines a sequence of tasks to run automatically on one or more remote hosts. Unlike one-off ad-hoc commands, playbooks are repeatable, version-controlled, and human-readable. They are the standard way to express automation with Ansible.
This guide covers Ansible playbook syntax, how to write and run your first playbook, how to use tags, and a complete real-world deployment example with Apache, Flask, and PostgreSQL.
We use a fictional company, TechCorp, to demonstrate setting up two web servers and one database server, provisioned with Vagrant and VirtualBox.
At a glance
Ansible is an agentless automation platform. Playbooks are its primary automation primitive: YAML files that describe the desired state of your infrastructure. Current stable release: ansible-core 2.20.4 (March 2026). Playbooks connect to managed hosts over SSH, push modules, and run tasks idempotently. env zero uses Ansible playbooks as templates, adding RBAC, drift detection, and audit logs on top of your existing playbook logic.
Video Tutorial
TLDR: You can find the main repo here.
In this guide:
- What are Ansible playbooks?
- Ansible playbook syntax
- Playbook vs. ad-hoc command vs. role
- How Ansible executes playbooks
- Setting up the environment
- Writing your first Ansible playbook
- Running Ansible playbooks
- Conditionals and handlers
- Managing variables and templates
- Organizing complex playbooks
- Ansible playbook tags
- Troubleshooting and optimization
- Ansible playbooks and env zero
- Frequently asked questions
What are Ansible playbooks?
Ansible playbooks are YAML-formatted files that define a series of tasks for Ansible to execute on managed hosts. Tasks are grouped into plays, each targeting specific hosts in a defined order.
Playbooks serve as a blueprint for configuring and managing systems, allowing you to describe the desired state in a clear, human-readable format.
Ansible uses YAML (Yet Another Markup Language) because it is both easy to read and machine-parsable, making it well-suited for configuration files.
A playbook groups tasks into plays. Each play targets a set of hosts and defines an ordered list of tasks to execute, such as installing a package or editing a file. Variables store values reused across tasks, and handlers are special tasks that run only when notified by another task, typically to restart a service after a configuration change.
Ansible playbook syntax
Every Ansible playbook shares the same core YAML structure. Here is a minimal working example using fully qualified collection names (FQCN), best practice since Ansible 2.10:
---
- name: Configure web servers
hosts: webservers
become: true
vars:
nginx_port: 80
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
- name: Start and enable nginx
ansible.builtin.service:
name: nginx
state: started
enabled: trueThe key fields:
- hosts: which inventory group or individual host to target. Required.
- become: run tasks as a privileged user (equivalent to
sudo). Required for most package and service operations. - vars: key-value pairs reusable throughout the play. Optional but common.
- tasks: an ordered list of actions. Each task calls one Ansible module. Required.
- name: a human-readable label for the play and each task, shown during execution. Omit it and your output becomes unreadable at scale.
Playbooks execute top-to-bottom. All tasks in a play complete on every targeted host before the next play begins. FQCN syntax (ansible.builtin.apt rather than just apt) removes ambiguity when multiple collections define modules with the same short name.
Project-level defaults live in ansible.cfg at the repo root: inventory path, remote user, SSH key, privilege escalation settings. Check the configuration reference for the full list of available options.
Ansible playbook vs. ad-hoc command vs. role
The three execution models serve different purposes:
| Ad-hoc command | Ansible playbook | Ansible role | |
|---|---|---|---|
| Format | CLI one-liner | Single YAML file | Directory tree of YAML, templates, vars |
| Use case | Quick one-off tasks (reboot, check a file) | Multi-step automation for one or more services | Reusable, shareable automation unit across projects |
| Repeatable | No (manual re-run) | Yes | Yes |
| Version-controlled | No | Yes | Yes |
| Complexity supported | Low | Medium to high | High |
| Team-shareable | No | Yes | Yes, via Ansible Galaxy |
| When to choose it | Debugging, spot checks | Standard automation work | Reuse across multiple playbooks or teams |
Start with playbooks. Promote to roles when the same playbook logic appears across multiple projects or teams, because roles package that logic with its own variables, defaults, handlers, and templates.
Related reading: Mastering Ansible variables. Variable scoping and precedence is the most common source of unexpected playbook behavior. Worth understanding before you scale.
Ansible's execution model

Ansible operates by connecting to your nodes (managed machines) over SSH and pushing out small programs called 'Ansible modules' to perform tasks. It executes tasks defined in your playbooks sequentially, ensuring each task is completed before moving to the next.
During execution, Ansible works idempotently, making changes only when the system isn't already in the desired state. This minimizes unnecessary modifications and reduces the risk of errors.
To illustrate how an Ansible playbook fits into the overall process, we use a fictional company, TechCorp. TechCorp automates the installation and configuration of software on web servers and a database server.
Setting up the environment
Before writing and running Ansible Playbooks, we need to set up our environment. Below is a diagram of what we will build.

Setting up Vagrant and VirtualBox
We'll use Vagrant and VirtualBox to provision virtual machines (VMs). Ensure both are installed on your system:
For this demo, I have Vagrant version 2.4.1 and VirtualBox version 7.0.20 r163906 (Qt5.15.2), both running on my Windows machine.
With Vagrant and VirtualBox installed, you can provision the VMs by navigating to the root of your project directory (where your Vagrantfile is located) and running:
This command will set up the control node, two web servers, and one database server as defined in your Vagrantfile found in your repo's root.
Run the following command to check the status of your VMs:
Below is a screenshot of the VMs in VirtualBox:

The Inventory File
An inventory file is a configuration file where you define the hosts and groups of hosts upon which Ansible will operate. You will find it in the root of your repo.
- [webservers]: A group containing webserver1 and webserver2.
- [dbservers]: A group containing a DB server.
Configuring SSH Connections
Ansible connects to target machines over SSH. To establish secure communication, you need public/private key pairs. Our vagrant file already creates and distributes these.SSH into the Control NodeGo ahead and run the command below to SSH into the control node from your local machine where you ran Vagrant:
Verify SSH Access from the Control Node to the Other Nodes
From the Control Node, try to SSH into the other nodes using:
If successful, you can proceed to use Ansible to manage these hosts.
Writing your first Ansible playbook
At the root of the repo, you will find this playbook called techcorp_playbook.yaml. This is our first and basic playbook to get us started. Here are its contents, followed by a detailed explanation:
Using Ansible modules
Ansible modules are the core components that execute specific actions on target hosts. They are pre-defined units of code that manage system resources, install software, control services, and more. The ansible.builtin module index lists every built-in module with parameters, examples, and return values. Modules simplify complex system interactions so playbooks can describe what should happen without scripting how to do it.
In our playbook for TechCorp, we're using several essential modules:
ansible.builtin.apt: Manages packages on Debian/Ubuntu systems. It allows installing, updating, or removing software packages using the Advanced Packaging Tool (APT).ansible.builtin.service: Controls services on remote hosts. It starts, stops, restarts, and manages the state of services.ansible.builtin.template: Transfers files from the control machine to target hosts, with the ability to substitute variables and apply logic using the Jinja2 templating language.ansible.builtin.command: Executes commands on remote hosts. It runs the command specified without using a shell, which is useful for running simple commands that don't require shell features.
Using modules, we write concise tasks that perform complex operations while keeping the playbook readable. Modules handle the OS-level details so the playbook stays focused on the desired end state.
Here is how each play and its tasks use these modules.
Explaining the playbook
First Play: Configure Web Servers
- Install Apache: Uses the apt module to ensure Apache is installed and up to date.
- Ensure Apache is running: Starts the Apache service and enables it to start on boot.
- Deploy Apache config file: Uses the template module to deploy a customized Apache configuration file.
- Handlers: They are triggered when the Apache package is installed or updated (Install Apache task). This task uses the notify statement to call the appropriate handler, ensuring Apache is restarted or reloaded after configuration changes.
Second Play: Configure Database Server
- Install PostgreSQL: Installs the latest version of PostgreSQL
- Ensure PostgreSQL is running: Starts and enables the PostgreSQL service
- Configure PostgreSQL: Deploys a custom configuration file using the template module
- Handlers: Reload PostgreSQL as needed
Running Ansible playbooks
Executing the playbook
Now it’s time to execute the playbook, using the [.code]ansible-playbook[.code] command from the control node:
The [.code]ansible-playbook[.code] command tells Ansible to use the inventory file and execute the tasks defined in techcorp_playbook.yaml file.
Once the playbook has been successfully completed, you can open a browser on your local machine and go to 192.168.56.101 or 192.168.56.102 to access the default Apache2 page on both web servers.

Understanding playbook execution
When you run the [.code]ansible-playbook[.code] command, Ansible initiates a series of actions to carry out the tasks defined in your playbook:
- Connection to target machines: Ansible connects to each target machine listed in your inventory file. It uses SSH for this connection, leveraging the user accounts and SSH keys you've set up earlier.
- Sequential task execution: On each host, Ansible executes the tasks sequentially as they appear in the playbook. This ensures that dependencies are respected and that each step is completed before moving on to the next.
- Idempotent operations: Ansible modules are designed to be idempotent, meaning that running them multiple times won't cause unintended changes if the system is already in the desired state. This is crucial for maintaining consistency across all systems.
- Handler notifications: If a task changes the state of a host (for example, by installing a package or modifying a configuration file), Ansible marks the task as "changed." Any handlers associated with that task via the [.code]notify[.code] directive are triggered but will run only after all tasks in the play have been executed.
- Handler execution: At the end of each play, Ansible executes any pending handlers. This is when services are reloaded or restarted based on the changes made during the tasks.
- Play recap: Upon completion, Ansible provides a summary of the playbook execution, indicating which tasks were successful, which made changes, and if any failed.
Interpreting the output
Ansible provides a detailed output of the playbook execution. The output indicates:
- ok: The task was already in the desired state or completed successfully without making changes.
- changed: The task made changes to the system to reach the desired state.
- unreachable: Ansible could not connect to the host.
- failed: The task did not complete successfully.
- skipped, rescued, ignored: Provide additional information about task execution flow.
Here is an example of how this output may look:
The output confirms that all tasks were executed successfully on the web servers and database server, as indicated by [.code]failed=0[.code] across all hosts in the play recap. This means there were no failures, and all tasks either completed successfully or made necessary changes.
That covers the basic setup: web servers and database server configured and running. The next section shows how to extend the playbook with conditionals, handlers, variables, and templates.
Conditionals and handlers
The advanced playbook builds on the basic setup above. It deploys a Flask application, connects it to the PostgreSQL database, and retrieves data to display in the browser.
This playbook sets up a Flask app, retrieves data from our PostgreSQL database, and displays it on the screen. You can check out the full code in the techcorp_playbook_advanced.yaml file in the root of the repo.
You can run this playbook from the control node with:
Once you’ve done that, you can go to either web server's IP address (192.168.56.101 or 192.168.56.102) in your browser, and you'll see the following screen:

The Flask app is running and retrieving items from the database. Here is what the advanced playbook adds.
Implementing conditional tasks
For an in-depth look at conditionals, including when statements, registered variables, and complex condition logic, see our ultimate guide to Ansible conditionals.
Conditional tasks allow you to execute tasks only when certain conditions are met. In the advanced playbook, we check if the default Apache site exists before attempting to disable it:
In this example:
- Check if default Apache site exists: Uses the stat module to check for the existence of the default site configuration file and registers the result in default_site.
- Disable default Apache site: Runs the [.code]a2dissite[.code] command only if default_site.stat.exists is ‘True’. This prevents errors if the site is already disabled or doesn't exist.
Handlers and notifications
Handlers are triggered by the notify directive when a task changes. Multiple tasks can notify the same handler, which will run only once after completing all tasks.
In the advanced playbook, handlers are extensively used to efficiently manage service restarts and reloads. For example, several tasks notify the Reload Apache handler:
The corresponding handler is defined as:
This ensures that Apache is reloaded only once after all required tasks have been executed, optimizing resource usage.
Managing variables and templates
Working with variables
Variables make your playbooks dynamic and reusable. In the advanced playbook, we define variables for the domain name and PostgreSQL version:
These variables are then used throughout the playbook to ensure consistency and make updates easier. Below are a couple of examples of using them:
Variables can be defined at multiple levels: in the playbook itself, in group_vars/ and host_vars/ directories alongside the inventory file, or passed on the command line with -e "variable=value". The Ansible variable precedence docs detail how these layers interact when the same variable is defined in multiple places.
Using templates for configuration files
Templates allow you to create dynamic configuration files using variables and control structures. They are written in Jinja2 templating language, which works directly with Ansible.
Apache Configuration Template (apache_flask.conf.j2)
You can find this template under the templates folder in your repo, and here is its content:
Note that this template uses the ‘{{ domain }}’ variable to customize the Apache configuration for the specified domain.
To deploy the template, simply use the task below:
PostgreSQL Configuration Template (pg_hba.conf.j2)
This template is another one found under the templates folder in your repo.
As the name suggests, it was created to configure PostgreSQL's client authentication settings, to allow secure connections from both local and remote clients.
The configuration above allows local connections over Unix sockets using peer authentication, permits TCP/IP connections from localhost (both IPv4 and IPv6) with MD5 password authentication, and enables remote connections from the web servers in the 192.168.56.0/24 subnet using MD5 authentication.
This ensures that your web servers can securely connect to the PostgreSQL database server while enforcing strong authentication methods, thereby maintaining the security and functionality of your application infrastructure.
Here is where we deploy the template in the playbook:
Applying to the example
In our advanced TechCorp example, using variables and templates provides several key benefits:
The domain variable keeps all references consistent across Apache configurations and any other place the domain appears. When the value changes, one update in the variables section propagates everywhere. The Apache and PostgreSQL templates can be reused across different environments with minimal adjustment, since the variable values are what differ, not the templates themselves.
Organizing complex playbooks
As a further enhancement to our example, we could also do the following.
Working with multiple plays
Separating plays for different server roles improves readability and maintenance.
Including and importing tasks
You can break down tasks into separate files for better modularity and reuse:
Roles and best practices
Roles allow you to group tasks, variables, files, and handlers into reusable components. Initialize a role with ansible-galaxy init my_role to scaffold the standard directory tree (tasks, defaults, handlers, templates, vars, meta). Reference it in a playbook with roles: [my_role]. Use roles when the same logic appears across multiple playbooks or teams.
A Possible Directory Structure:
Enhancing the example with roles
Refactor your playbook to use roles:
This approach improves maintainability and scalability as your infrastructure grows.
Ansible playbook tags
Tags let you run a subset of tasks in a playbook without executing the whole file. This is useful during development (run only the tasks you just changed) or in CI pipelines (run deploy tasks only, skip the initial setup that already ran).
Assign tags to any task:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
tags:
- install
- webserver
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
tags:
- deploy
- webserverRun only tasks with a specific tag:
ansible-playbook site.yml -i inventory.ini --tags "install"Run multiple tags:
ansible-playbook site.yml -i inventory.ini --tags "install,deploy"Skip tasks with a tag:
ansible-playbook site.yml -i inventory.ini --skip-tags "install"Two built-in special tags:
always: the task runs regardless of which--tagsare passed. Use for fact-gathering, prerequisite checks, or tasks that must run in every execution.never: the task only runs if explicitly requested with--tags never. Use for debug tasks or destructive operations you want to gate.
- name: Gather facts (always runs)
ansible.builtin.setup:
tags: always
- name: Nuke database (never runs unless requested)
ansible.builtin.command: drop_all_tables.sh
tags: neverRelated reading: The ultimate guide to Ansible conditionals. Tags control which tasks run at the command line; conditionals (
when:) control which tasks run based on facts and variables at runtime.
Troubleshooting and optimization
Debugging playbooks
Increase verbosity to get more detailed output for troubleshooting:
Perform a syntax check before running the playbook:
For a more thorough check, run ansible-lint against your playbooks. It catches style issues, deprecated syntax, and common mistakes that --syntax-check misses. Add --diff to any ansible-playbook run to see exactly what changes a task would make to files before applying them — particularly useful with the ansible.builtin.template and ansible.builtin.copy modules.
Optimizing task execution
Limit Execution to Specific Hosts:
Disable Fact Gathering if not needed, to speed up execution:
The [.code]ANSIBLE_GATHERING=explicit[.code] environment variable sets the gathering mode to "explicit" for the duration of this command.
In "explicit" mode, Ansible will only gather facts when explicitly requested in the playbook or with the [.code]gather_facts: true[.code] directive.
Security considerations
Use Ansible Vault to encrypt sensitive data (see our full guide to protecting secrets with Ansible Vault):
This command encrypts the vars/secrets.yaml file, ensuring sensitive information like passwords, API keys, and confidential variables are secure. Only users with the vault password can access and decrypt the contents of this file.
Ensuring Authorized Access:
- Access Control: It's essential to manage who has access to your playbooks and encrypted files. Limit permissions to authorized users only.
- Version Control: Be cautious when committing encrypted files to version control systems. While the content is encrypted, access to the encrypted files should still be restricted.
Ansible Vault is beyond the scope of this article, but worth mentioning here for a full-picture view.
Ansible playbooks and env zero
Running Ansible playbooks locally works for a single engineer. The problems surface when teams grow: multiple people running automation against shared infrastructure, no audit trail, no approval gate before production changes. env zero adds a control plane over your existing playbooks without changing a line of YAML.
Why use env zero with Ansible playbooks
Every run is logged, attributed to a specific user, and subject to role-based access control (RBAC). Approval workflows can require a reviewer before a playbook touches a production environment. Drift detection flags when live infrastructure diverges from the last applied state. Teams get full visibility into what ran, when, and what changed, without granting direct SSH access to managed nodes.
How to integrate Ansible with env zero

- Connect your code repositories: Link your repositories containing Ansible Playbooks to an env0 Ansible template.
- Configure projects: Organize your environments within env0 projects where you can call on a template to run.
- Run playbooks: Execute playbooks from env0, which uses the [.code]ansible-playbook[.code] command behind the scenes by deploying an environment.
- Monitor and manage: Use env0's dashboard for execution insights, variable management, and output handling.
What env zero adds to playbook runs
env zero adds variable management across environments, custom CI/CD workflows, and role-based access control so teams can share playbooks without sharing credentials or bypassing review processes.
env zero with Ansible playbooks in action
Near the end of the demo video, at the top of this article, I show you the env0 Ansible template and how you can use it to run Ansible playbooks from env0. You can also reference The Ultimate Ansible Tutorial: A Step-by-Step Guide to learn more about getting started with Ansible and env zero.
To learn more about how env0’s platform can help with governance and automation, check out this guide: The Four Stages of Infrastructure as Code (IaC) Automation.
What's next
- Protecting secrets with Ansible Vault: encrypt sensitive variables and files so passwords and API keys never appear in plaintext in your playbooks or version control.
- Ansible conditionals:
when:statements, registered variables, and complex condition logic for building decision-making into your playbooks. - Mastering Ansible variables: variable scoping, precedence, and the most common sources of unexpected behavior.
- Scale with env zero: use env zero to run Ansible playbooks with RBAC, drift detection, and full audit logs across your infrastructure. See how it works.
Try it with env zero
Running Ansible playbooks locally works until it doesn't. When team size grows, who ran what and when becomes opaque, and enforcing policy across playbook runs requires custom tooling. env zero adds collaborative workflows, role-based access control, and audit logs on top of your existing playbooks without changing a line of YAML.
Start a free trial or book a demo.
Frequently asked questions
Q: What is the ansible-playbook command used for?
The [.code]ansible-playbook[.code] command is used to execute Ansible Playbooks. It reads the YAML-formatted playbook files and runs the tasks defined within them against the specified hosts or groups.
Q: How do I run a specific play or task within a playbook?
You can use tags to run specific plays or tasks. Add a tags attribute to your tasks or plays and then run:
Q: What is an ad hoc command in Ansible, and when should I use it?
An [.code]ad hoc[.code] command in Ansible is a one-liner used to execute a quick task without requiring a playbook file. It's great for simple tasks like restarting services or checking the state of a system.
Q: Can I run Ansible Playbooks on multiple environments?
Yes, you can manage multiple environments by defining different Ansible inventory files or using dynamic inventory scripts. This allows you to target different sets of hosts without changing your playbooks.
Q: How do I handle secrets and sensitive data in Ansible Playbooks?
Use Ansible Vault to encrypt sensitive variables and files. This ensures that secrets like passwords and API keys are stored securely and are only accessible to authorized users.
Q: What are handlers, and how do they work in Ansible Playbooks?
Handlers are tasks that run only when notified by other tasks. They're typically used to restart or reload services after a configuration change. Handlers ensure that services are only restarted when necessary, optimizing performance.
Q: What is the purpose of an Ansible inventory file?
An Ansible inventory file defines the hosts and groups of hosts on which tasks are executed. It can be a static file or dynamically generated based on your infrastructure.
Q: What are ad hoc tasks versus playbook tasks in Ansible?
Ad hoc tasks are one-off commands executed for quick actions, while playbook tasks are predefined and reusable tasks defined in playbook files. Use ad hoc commands for immediate needs and playbooks for long-term automation.
Q: What is the ansible-playbook run syntax?
The basic command is ansible-playbook -i inventory.ini site.yml. The -i flag specifies the inventory file. Common additions: -v (verbose output), --check (dry run, shows changes without applying them), --limit webserver1 (target one host instead of the full group), --tags "deploy" (run only tagged tasks). Run ansible-playbook --help for the full flag reference.
Q: How do I use tags to run specific parts of an Ansible playbook?
Add a tags: key to any task, then pass --tags "tagname" at runtime to execute only those tagged tasks. Use --skip-tags "tagname" to exclude them. Multiple tags can be comma-separated: --tags "install,configure". The built-in always tag forces a task to run regardless of which tags are specified. The built-in never tag means the task only runs when explicitly requested.

.webp)

![Using Open Policy Agent (OPA) with Terraform: Tutorial and Examples [2026]](https://cdn.prod.website-files.com/63eb9bf7fa9e2724829607c1/69d6a3bde2ffe415812d9782_post_th.png)