You have inventory and SSH working—now you need a playbook that does something useful on managed hosts. This tutorial walks you through one play that installs a package, manages a service, creates a directory, and copies a file, using the same small project layout you will keep as the playbooks grow.
This page is a hands-on first playbook. It does not re-teach YAML indentation, full playbook anatomy, or every ansible-playbook flag—those live in dedicated guides linked below.
Tested on: Rocky Linux 10.2 (Red Quartz); kernel 6.12.0-211.16.1.el10_2.0.1.x86_64; ansible-core 2.16.16.
~/ansible-project, inventory group lab, and playbooks in playbooks/. Use your own host names and paths if yours differ.
What You Will Build in This Ansible Playbook Tutorial
By the end you will have:
- A minimal project tree with
ansible.cfg, inventory, andplaybooks/ - A first playbook that Ansible can parse and run against group
lab - Tasks that install a web package, start and enable its service, create
/opt/first-app, and copy a small text file into that directory - A combined
first-playbook.ymlyou can re-run safely after--syntax-checkand--check
Playbooks are reusable automation files. Use them when the same steps must run in order more than once; one-off checks stay in ad hoc commands.
Before You Write Your First Playbook
Confirm these pieces are already in place:
| Prerequisite | Why it matters |
|---|---|
| Ansible installed on the control node | You need ansible-playbook |
| SSH access to managed hosts | Playbooks connect the same way ansible ad hoc does |
| A working inventory | The play hosts: pattern must match real hosts |
From the control node, a quick connectivity check:
cd ~/ansible-project
ansible lab -m pingSample output:
rocky2 | SUCCESS => {
"changed": false,
"ping": "pong"
}If ping fails, fix inventory or SSH before writing tasks—you will only see clearer errors later.
Create a Simple Ansible Project Directory
You do not need a large tree for your first playbook. A small layout is enough:
~/ansible-project/
├── ansible.cfg
├── inventory/
│ └── hosts
└── playbooks/
├── first-playbook.yml
└── files/
└── motd-snippet.txtansible.cfg points at inventory/hosts so you can run ansible-playbook playbooks/first-playbook.yml without -i every time. See project directory structure when you add group_vars/, roles, or more playbooks.
Create the static file the copy task will deploy:
mkdir -p ~/ansible-project/playbooks/files
cat > ~/ansible-project/playbooks/files/motd-snippet.txt << 'EOF'
Managed by Ansible - first playbook tutorial
EOFCreate Your First Ansible Playbook
Start with the smallest useful play: a name, target hosts, and one task that proves connectivity.
cat > ~/ansible-project/playbooks/hello-lab.yml << 'EOF'
---
- name: Hello lab
hosts: lab
gather_facts: false
tasks:
- name: Ping managed hosts
ansible.builtin.ping:
EOFThe leading --- marks a YAML document. The list item starting with - is one play. Each task under tasks: is another list item.
Understand the First Playbook
This example uses only a handful of keys:
| Key | Role in this tutorial |
|---|---|
name |
Human-readable play title in output (PLAY [Hello lab]) |
hosts |
Inventory pattern—in this case group lab |
gather_facts: false |
Skip the fact-gathering step for a faster connectivity check |
tasks |
Ordered steps Ansible runs on each matched host |
Task name |
Label shown for each TASK [...] line |
| Module | ansible.builtin.ping checks connectivity and Python on the target |
For plays, handlers, modules, and execution order in depth, read Ansible playbook structure. This tutorial only introduces what the first example needs.
Run the Ansible Playbook
Run the playbook from the project root so ansible.cfg and inventory resolve:
cd ~/ansible-project
ansible-playbook playbooks/hello-lab.ymlSample output:
PLAY [Hello lab] ***************************************************************
TASK [Ping managed hosts] ******************************************************
ok: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=1 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0This playbook does not use become—ping does not need root. You will add privilege escalation in first-playbook.yml when tasks install packages and write under /opt. For --limit, tags, check mode, and ansible-navigator, see how to run Ansible playbooks.
Add Multiple Tasks to the Playbook
Real playbooks chain several tasks in one play. You will build first-playbook.yml with four practical steps:
- Install a package
- Create an application directory
- Copy a file into that directory
- Start and enable the service
Each task calls one module with arguments. Order matters when later tasks depend on earlier ones—for example, create the directory before copying into it.
Install a Package Using a Playbook
The package module uses the target OS package manager behind the scenes (dnf on Rocky/RHEL, apt on Debian/Ubuntu). This keeps the task syntax generic, but package names are still OS-specific. In this Rocky/RHEL example, the package and service are both httpd; on Debian/Ubuntu, Apache is usually apache2. For Rocky Linux repository layout and idempotent dnf tasks beyond this walkthrough, see manage packages on Rocky Linux.
- name: Install web package
ansible.builtin.package:
name: "{{ web_package }}"
state: presentstate: present ensures the package is installed. A second run reports ok when the package is already there because the package module knows the desired state.
Start and Enable a Service Using a Playbook
After the package is installed, start the service now and enable it at boot:
- name: Start and enable web service
ansible.builtin.service:
name: "{{ web_package }}"
state: started
enabled: truestate: started affects the current running service. enabled: true controls whether the service starts automatically after reboot.
On newer systems you may see ansible.builtin.systemd in other guides; service remains a clear choice for a first playbook. Restarting only when a config file changes is a job for handlers—covered later, not in this walkthrough.
Create a File or Directory Using a Playbook
Use the file module for paths, permissions, and ownership:
- name: Ensure application directory exists
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
owner: root
group: root
mode: "0755"state: directory creates the path when it is missing. Quoting mode avoids YAML interpreting leading zeros as octal incorrectly.
Copy a File to Managed Nodes
The copy module pushes content from the control node to managed hosts. Place sources under playbooks/files/ next to the playbook (or in a role files/ tree later).
- name: Deploy motd snippet
ansible.builtin.copy:
src: motd-snippet.txt
dest: "{{ app_dir }}/motd-snippet.txt"
owner: root
group: root
mode: "0644"With this layout, Ansible can find motd-snippet.txt under playbooks/files/. In roles, the same idea moves to the role's files/ directory. For backup, validate, remote_src, and fetch (pull files back to the controller), see the dedicated file, copy and fetch modules guide. For module discovery and ansible-doc, see modules and collections.
Use become for Privileged Tasks
Installing packages, writing under /opt, and managing system services require root on the managed host. Setting become: true at play level applies escalation to every task in that play.
Your ansible.cfg may already set become = True under [privilege_escalation]. If not, add it in YAML:
become: trueYou can also pass -b on the CLI when the playbook omits become—see the run playbooks guide. Use -b -K when sudo requires a password.
Add Simple Variables to the Playbook
Play-level vars keep repeated values in one place:
vars:
app_dir: /opt/first-app
web_package: httpdTasks reference them as {{ app_dir }} and {{ web_package }}. This tutorial stops at simple play vars. Variable types and access are in Ansible variables; inventory placement and precedence are in group_vars and host_vars.
Check Playbook Syntax
After editing YAML, validate structure before connecting to hosts:
ansible-playbook playbooks/first-playbook.yml --syntax-checkSample output:
playbook: playbooks/first-playbook.ymlIf the command prints only the playbook path and exits cleanly, Ansible could read the YAML and understand the playbook structure. It does not prove the package exists on the target, that sudo works, or that every module argument is valid for your OS. See YAML validation when parse errors point at indentation.
Run the Playbook in Check Mode
Check mode previews changes without applying them:
ansible-playbook playbooks/first-playbook.yml --checkSample output:
PLAY RECAP *********************************************************************
rocky2 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0changed in check mode means Ansible predicts a change—it does not mean the system was modified. If /opt/first-app already exists from an earlier run, fewer tasks may show changed. Module support varies; treat check mode as a preview, not a production gate. More flags (--diff, --list-hosts, tags) are in the run playbooks guide.
View Playbook Output and Recap
Each task line shows a status per host:
| Status | Meaning |
|---|---|
ok |
Task succeeded; no change needed |
changed |
Task modified the system |
failed |
Task returned an error |
unreachable |
Ansible could not connect or execute on the host |
skipped |
Task not run (condition, tag, or check-mode behavior) |
PLAY RECAP totals those results per host. A healthy second run often shows changed=0 when everything is already configured:
ansible-playbook playbooks/first-playbook.ymlSample output:
PLAY RECAP *********************************************************************
rocky2 : ok=5 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Add -v when you need module JSON for debugging—details in the run playbooks guide.
Common First Playbook Mistakes
| Symptom | Likely cause | Fix |
|---|---|---|
Permission denied on package or file task |
Missing become |
Set become: true or use -b |
Could not find or access on copy |
src not under files/ |
Put the file in playbooks/files/ or fix the path |
Failed to connect to the host |
SSH or wrong ansible_host |
Test ssh and ansible lab -m ping first |
When to Split a Playbook into Roles
One playbook is fine while you are learning. Split into roles when:
- The same task blocks repeat in multiple playbooks
- Files and variables are hard to navigate in a single YAML file
- You want defaults, handlers, and templates bundled per application
Keep this tutorial as a single play. Refactor to a role after the workflow is familiar.
Complete First Playbook Example
Combined playbooks/first-playbook.yml:
cat > ~/ansible-project/playbooks/first-playbook.yml << 'EOF'
---
- name: First playbook lab
hosts: lab
become: true
vars:
app_dir: /opt/first-app
web_package: httpd
tasks:
- name: Install web package
ansible.builtin.package:
name: "{{ web_package }}"
state: present
- name: Ensure application directory exists
ansible.builtin.file:
path: "{{ app_dir }}"
state: directory
owner: root
group: root
mode: "0755"
- name: Deploy motd snippet
ansible.builtin.copy:
src: motd-snippet.txt
dest: "{{ app_dir }}/motd-snippet.txt"
owner: root
group: root
mode: "0644"
- name: Start and enable web service
ansible.builtin.service:
name: "{{ web_package }}"
state: started
enabled: true
EOFThis final playbook intentionally keeps everything in one file. That makes the first run easy to understand. Later, the same package, file, copy, and service tasks can move into a role.
Run it:
ansible-playbook playbooks/first-playbook.ymlSample output:
PLAY [First playbook lab] ******************************************************
TASK [Gathering Facts] *********************************************************
ok: [rocky2]
TASK [Install web package] *****************************************************
ok: [rocky2]
TASK [Ensure application directory exists] *************************************
changed: [rocky2]
TASK [Deploy motd snippet] *****************************************************
changed: [rocky2]
TASK [Start and enable web service] ********************************************
changed: [rocky2]
PLAY RECAP *********************************************************************
rocky2 : ok=5 changed=3 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0Verify on the managed host:
ansible lab -m command -a "ls -l /opt/first-app/motd-snippet.txt"Sample output:
rocky2 | CHANGED | rc=0 >>
-rw-r--r--. 1 root root 45 Jul 7 20:57 /opt/first-app/motd-snippet.txtWhat to Learn Next
| Topic | Guide |
|---|---|
| Idempotency and safe reruns | Ansible idempotency |
Facts and setup |
Ansible facts |
| Conditionals | when conditionals |
| Loops | Ansible loop |
| Templates | Jinja2 templates |
Summary
Create a small project with ansible.cfg, inventory, and playbooks/, then write a play with name, hosts, and an ordered tasks list. Add become when tasks need root. Use package, file, copy, and service for common first-automation steps; add simple vars for values you reuse. Validate with --syntax-check, preview with --check, and read PLAY RECAP for ok, changed, failed, and unreachable. When the playbook outgrows one file, move tasks into roles—not before you have one working end-to-end example.
References
- Ansible playbook guide — official introduction
- ansible-playbook command — CLI reference
- Ansible YAML syntax — indentation and validation
- Ansible playbook structure — plays, tasks, handlers
- Run Ansible playbooks — syntax check, check mode, limits, tags

