Ansible Playbooks Explained: From First YAML File to Managing Real Servers
If you have already run a few Ansible ad hoc commands and seen how they work, you already understand the core idea: one command, many servers. But ad hoc commands only take you so far. When you need to install software, start services, create users, copy files, and print confirmation messages, all in one automated run across multiple servers, that is when you move to playbooks. Playbooks are where Ansible truly earns its place in a DevOps workflow. Everything you do in Ansible at scale, you do through playbooks.

What Is a Playbook?
A playbook is a YAML file where you define what Ansible should do on your remote servers. It is a structured list of tasks, and each task calls an Ansible module to do something specific. One playbook can have as many tasks as you need, and every task runs in order, on every server you target.
The playbook structure always follows the same pattern:
---
- name: <playbook description>
hosts: <target group or all>
become: yes
tasks:
- name: <task description>
<module_name>:
<argument>: <value>
Let us break down each part of this structure before writing any actual playbooks.
The Structure of a Playbook
The Three Dashes at the Top
Every Ansible playbook starts with ---. This is a YAML convention that marks the beginning of a YAML document. Always include it.
name
This is just a description. It tells you and anyone else reading the playbook what this playbook is about. It appears in the terminal output when the playbook runs, so write something meaningful.
hosts
This tells Ansible where to run the playbook. You can put all to target every server in your inventory, or you can put a group name like prod or dev to target only those servers. This maps directly to the groups you defined in /etc/ansible/hosts.
become: yes
This is the equivalent of running commands with sudo. Whenever you are installing software, starting or stopping services, or doing anything that requires elevated permissions, you must include become: yes. Without it, your tasks will fail on protected operations.
A good rule to follow: if you are installing or managing services, always add become: yes. If you are only checking connectivity or reading information, you can skip it.
tasks
This is the list of things Ansible will do. Each task has a name (description) and a module with its arguments.
Checking Playbook Syntax Before Running
Before you run any playbook, especially one you just wrote, always check the syntax first. One wrong indentation in YAML can break everything.
The command is:
ansible-playbook pb1.yml --syntax-check
If there are no issues, Ansible will say the playbook is fine. If there is a problem, it will point to exactly where the error is.
YAML is incredibly sensitive to indentation. A single extra space or a missing space causes syntax errors. When Ansible shows you a syntax error, it tells you the approximate line where the problem is. Fix it, run the check again, and then proceed.
Your First Playbook: Testing Connectivity
Before installing anything on remote servers, it is a good habit to first confirm that Ansible can reach all of them. A simple ping playbook does exactly that.
---
- name: Ping playbook
hosts: all
tasks:
- name: Connectivity check
ping:
Save this as pb1.yml. To run it:
ansible-playbook pb1.yml
When you run this, you will notice Ansible does two things before executing your task. First, it runs Gathering Facts.
Understanding Gathering Facts
By default, every time a playbook runs, Ansible first connects to each target server and collects system information. This is called Gathering Facts. It checks things like the operating system, available memory, IP addresses, and other system details.
You can disable it if you want to speed things up on large inventories, but by default it runs automatically. Think of it as Ansible doing a quick health check before getting to work.
After Gathering Facts completes, your tasks execute.
Reading the Ansible Output: Color Codes
One thing that immediately stands out when you run Ansible is the colors in the terminal output. They are not decorative. Each color carries meaning:
Green means the task ran successfully and nothing changed. The system was already in the desired state.
Yellow means the task ran successfully and something changed. A package was installed, a service started, a file was created.
Red means the task failed.
At the end of every playbook run, Ansible prints a recap summarizing what happened across all servers. It shows how many tasks were OK, how many had changes, how many were unreachable, and how many failed. Get comfortable reading this recap. It tells you everything you need to know at a glance.
Installing Apache with a Playbook
Now let us write a playbook that actually does something useful: install Apache on all remote servers.
---
- name: Installing Apache
hosts: all
become: yes
tasks:
- name: Install Apache
yum:
name: httpd
state: present
- name: Print message
debug:
msg: "Apache installed successfully"
Save this as pb2-apache.yml. Run it with:
ansible-playbook pb2-apache.yml
A few things to notice here.
The yum module handles package management on Red Hat based systems like Amazon Linux 2. You give it the package name and a state. state: present means "make sure this package is installed." If it is already installed, Ansible skips it and shows green. If it is not installed, Ansible installs it and shows yellow.
The debug module prints a message to the terminal. You use it to confirm that a step completed or to show any information you want visible in the output. The parameter is msg, and you set it to whatever string you want printed.
When this playbook runs, you will see yellow for the Apache installation (because a change happened) and green or yellow for the debug message. The recap will show changed=1, confirming that Apache was installed.
Installing Apache and Starting the Service
Installing Apache is only half the job. You also need to start the service. Let us expand the playbook to handle both.
---
- name: Install and start Apache
hosts: all
become: yes
tasks:
- name: Install Apache
yum:
name: httpd
state: present
- name: Print install message
debug:
msg: "Apache installed"
- name: Start Apache service
service:
name: httpd
state: started
The service module manages system services. You give it the service name and the desired state. state: started means "make sure this service is running." If it is already running, Ansible does nothing and shows green. If it is stopped, Ansible starts it and shows yellow.
After running this playbook, you can quickly verify the service status using an ad hoc command without writing another playbook:
ansible all -a "systemctl status httpd"
That is an important habit to develop. Not everything needs a playbook. Use ad hoc commands for quick one-time checks. Use playbooks for repeatable, structured work.
Installing Git and Docker
Let us write a more complete playbook that installs multiple packages and starts a service.
---
- name: Install Git and Docker
hosts: all
become: yes
tasks:
- name: Install Git
yum:
name: git
state: present
- name: Install Docker
yum:
name: docker
state: present
- name: Start Docker service
service:
name: docker
state: started
Save this as pb4-git-docker.yml and run it:
ansible-playbook pb4-git-docker.yml
Ansible will work through the tasks in order. It installs Git, installs Docker, then starts the Docker service. Each task that causes a change will appear in yellow. The recap at the end tells you how many changes happened across how many servers.
This is where the real value of Ansible becomes obvious. A single playbook file handles all three tasks across every server in your inventory simultaneously. Imagine doing this manually across forty servers.
Two Ways to Write Module Arguments
You may have noticed there are two ways to write module arguments in YAML. Both are valid.
Inline style:
- name: Install Git
yum: name=git state=present
Block style (recommended):
- name: Install Git
yum:
name: git
state: present
The block style is more readable and is the standard convention. Use it consistently, especially when a module takes multiple arguments.
Creating Users and Copying Files
Ansible is not limited to package management. You can also create system users and copy files to remote machines.
First, create a file locally that you want to copy:
touch file2
Now write a playbook:
---
- name: Create users and copy files
hosts: all
tasks:
- name: Create user
user:
name: alex
state: present
- name: Copy file to remote servers
copy:
src: file2
dest: /home/ec2-user/
The user module creates system users. Give it a name and state: present to create the user.
The copy module copies a file from the Ansible master to the remote servers. src is the path to the file on the master. dest is where it should land on the remote machine.
After running this playbook, you can verify the file was copied by checking the remote machine directly or using an ad hoc command.
Removing Software with state: absent
The same playbook you used to install software can be used to remove it. The only change is the state value.
state: present installs a package. state: absent removes a package.
Instead of rewriting the whole playbook manually, you can use sed to do a global find-and-replace directly in the file:
sed -i 's/present/absent/g' pb4-git-docker.yml
This command replaces every occurrence of present with absent throughout the file. Verify the change with:
cat pb4-git-docker.yml
Now run the playbook again:
ansible-playbook pb4-git-docker.yml
Ansible will uninstall Git and Docker. However, notice what happens with the Docker service task. Once Docker is removed, Ansible tries to stop a service that no longer exists. That task will fail with an error. This brings us to an important concept.
Handling Expected Errors with ignore_errors
When you uninstall a package and then have a task that manages its service, that service task will naturally fail because the service no longer exists. In this case, the failure is expected and you do not want it to stop the playbook or alarm anyone reading the output.
Add ignore_errors: true to the task that you expect might fail:
- name: Start Docker service
service:
name: docker
state: started
ignore_errors: true
Now when you run the playbook, instead of showing a red failure, Ansible will show the word ignoring next to that task. The playbook continues running, and the recap reflects the ignored error clearly.
This is useful in patching workflows and uninstall scripts where certain follow-up tasks are not applicable once a package is gone.
Using the command Module
Sometimes you need to run a Linux command that does not have a dedicated Ansible module. For those situations, the command module lets you pass any shell command directly.
- name: Run a custom command
command: your-linux-command-here
This is particularly useful when you need to do something specific to your Linux distribution, like enabling a package repository that is not available by default.
Installing Nginx on Amazon Linux 2
Here is where things get a little more interesting. If you try to install Nginx on Amazon Linux 2 using just yum install nginx, it will fail. The reason is that the Nginx package is not included in the default Amazon Linux 2 repositories.
Every Linux system has a local repository that the package manager checks before downloading and installing anything. If a package is not registered in that repository, the package manager cannot find it and the installation fails.
For Amazon Linux 2, Nginx is available through Amazon Linux Extras, which is a separate package management layer that provides additional software not in the default repos. You must enable the Nginx repository through Amazon Linux Extras before you can install it.
Here is the playbook:
---
- name: Install Nginx and start service
hosts: all
become: yes
tasks:
- name: Enable Nginx repository
command: amazon-linux-extras enable nginx1
- name: Install Nginx
yum:
name: nginx
state: present
- name: Start Nginx service
service:
name: nginx
state: started
The first task uses the command module to run amazon-linux-extras enable nginx1, which registers the Nginx repository on the machine. Once that runs, the next yum task can find and install Nginx successfully.
After Nginx is installed and started, you can drop an index.html file into the Nginx web root and access your server's public IP in a browser to see the page load.
A Full Reference: Modules Covered
Here is a quick summary of every module used in this article:
| Module | What It Does |
|---|---|
ping |
Tests connectivity to remote hosts |
yum |
Installs or removes packages on RHEL/Amazon Linux |
service |
Starts, stops, or restarts system services |
debug |
Prints a message to the terminal output |
user |
Creates or removes system users |
copy |
Copies files from master to remote servers |
command |
Runs any Linux shell command on remote servers |
Ad Hoc Commands vs Playbooks: When to Use Which
A natural question comes up once you start working with playbooks: when should you use an ad hoc command and when should you write a playbook?
The answer is straightforward.
Use ad hoc commands for quick, one-time actions. Checking a service status, verifying a package version, reading a file. Anything you would type once and not need again.
Use playbooks for anything repeatable. Installing software stacks, configuring servers, deploying applications, patching systems. Any workflow you need to run more than once, or across multiple environments, belongs in a playbook.
Playbooks are also self-documenting. The name fields on every play and task describe exactly what is happening. When someone else (or future you) reads the playbook six months later, it is clear what it does without reading the actual module arguments.
Summary
Ansible playbooks are structured YAML files that define exactly what should happen on your remote servers. A playbook has a name, a target host group, an optional privilege escalation setting, and a list of tasks. Each task calls a module with specific arguments.
The key concepts covered here:
Always start playbooks with
---and validate syntax with--syntax-checkbefore runningAnsible gathers system facts by default at the start of every playbook run
Green output means no change happened. Yellow means something changed. Red means something failed.
become: yesis required whenever your tasks need elevated permissionsstate: presentinstalls a package.state: absentremoves it.ignore_errors: truelets a playbook continue past a task that is expected to failThe
commandmodule lets you run any Linux command when no dedicated module existsAmazon Linux 2 requires enabling the Nginx repository through
amazon-linux-extrasbefore installing Nginx
One well-written playbook can manage hundreds of servers simultaneously. That is the point of Ansible, and playbooks are how you get there.





