Write Your First Ansible Playbook: Package, Service, File, and Copy

Write your first Ansible playbook step by step—project layout, package and service tasks, file and copy modules, become, simple vars, syntax check, check mode, and PLAY RECAP.

Published

Updated

Read time 10 min read

Reviewed byDeepak Prasad

Write your first Ansible playbook with package, service, file, and copy tasks on Rocky Linux 10

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.

NOTE
This chapter is part of the GoLinuxCloud Ansible tutorial (RHCE EX294). Follow along from ~/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, and playbooks/
  • 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.yml you can re-run safely after --syntax-check and --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:

bash
cd ~/ansible-project
ansible lab -m ping

Sample output:

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:

text
~/ansible-project/
├── ansible.cfg
├── inventory/
│   └── hosts
└── playbooks/
    ├── first-playbook.yml
    └── files/
        └── motd-snippet.txt

ansible.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:

bash
mkdir -p ~/ansible-project/playbooks/files
cat > ~/ansible-project/playbooks/files/motd-snippet.txt << 'EOF'
Managed by Ansible - first playbook tutorial
EOF

Create Your First Ansible Playbook

Start with the smallest useful play: a name, target hosts, and one task that proves connectivity.

bash
cat > ~/ansible-project/playbooks/hello-lab.yml << 'EOF'
---
- name: Hello lab
  hosts: lab
  gather_facts: false
  tasks:
    - name: Ping managed hosts
      ansible.builtin.ping:
EOF

The 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:

bash
cd ~/ansible-project
ansible-playbook playbooks/hello-lab.yml

Sample output:

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=0

This playbook does not use becomeping 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:

  1. Install a package
  2. Create an application directory
  3. Copy a file into that directory
  4. 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.

yaml
- name: Install web package
      ansible.builtin.package:
        name: "{{ web_package }}"
        state: present

state: 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:

yaml
- name: Start and enable web service
      ansible.builtin.service:
        name: "{{ web_package }}"
        state: started
        enabled: true

state: 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:

yaml
- 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).

yaml
- 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:

yaml
become: true

You 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:

yaml
vars:
    app_dir: /opt/first-app
    web_package: httpd

Tasks 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:

bash
ansible-playbook playbooks/first-playbook.yml --syntax-check

Sample output:

output
playbook: playbooks/first-playbook.yml

If 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:

bash
ansible-playbook playbooks/first-playbook.yml --check

Sample output:

output
PLAY RECAP *********************************************************************
rocky2                     : ok=5    changed=3    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

changed 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:

bash
ansible-playbook playbooks/first-playbook.yml

Sample output:

output
PLAY RECAP *********************************************************************
rocky2                     : ok=5    changed=0    unreachable=0    failed=0    skipped=0    rescued=0    ignored=0

Add -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:

bash
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
EOF

This 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:

bash
ansible-playbook playbooks/first-playbook.yml

Sample output:

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=0

Verify on the managed host:

bash
ansible lab -m command -a "ls -l /opt/first-app/motd-snippet.txt"

Sample output:

output
rocky2 | CHANGED | rc=0 >>
-rw-r--r--. 1 root root 45 Jul  7 20:57 /opt/first-app/motd-snippet.txt

What 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

Frequently Asked Questions

1. What should a first Ansible playbook contain?

At minimum: a play name, hosts pattern, tasks list, and modules such as ping, package, service, file, or copy. Add become when tasks need root.

2. Do I need become on every playbook?

No. Use become when tasks install packages, manage services, or write system paths. Read-only checks like ping often do not need it unless your site policy requires escalation for every task.

3. What is the difference between ok and changed in PLAY RECAP?

ok means the task succeeded without modifying the system. changed means Ansible updated something. failed and unreachable mean the run did not finish cleanly on that host.

4. Should I run --syntax-check before every playbook run?

Run it after every significant edit. It catches YAML and playbook keyword problems before Ansible connects to managed hosts, but it does not prove every task will succeed.

5. When should I move from one playbook to roles?

When the same tasks repeat across playbooks, files grow hard to navigate, or you want reusable units with defaults and handlers. Start with one clear playbook first.
Deepak Prasad

R&D Engineer

Founder of GoLinuxCloud with more than 15 years of expertise in Linux, Python, Go, Laravel, DevOps, Kubernetes, Git, Shell scripting, OpenShift, AWS, Networking, and Security. With extensive …