Introduction
An Ansible playbook is a YAML file that defines automation tasks — install packages, deploy configs, restart services, create users. It's the core of Ansible: you describe what you want (desired state), and Ansible figures out how to get there.
Minimal Playbook
---
- name: My first playbook
hosts: all
become: true
tasks:
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
Run it:
ansible-playbook -i inventory playbook.yml
Anatomy of a Playbook
---
# A playbook contains one or more PLAYS
- name: Configure web servers # Play 1
hosts: webservers # Which hosts to target
become: true # Run as root (sudo)
vars: # Variables for this play
http_port: 80
tasks: # List of tasks to execute
- name: Install nginx # Task 1
ansible.builtin.apt:
name: nginx
state: present
- name: Start nginx # Task 2
ansible.builtin.service:
name: nginx
state: started
enabled: true
handlers: # Run only when notified
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
- name: Configure databases # Play 2
hosts: dbservers
become: true
tasks:
- name: Install PostgreSQL
ansible.builtin.apt:
name: postgresql
state: present
Key Components
| Component | Purpose |
|---|---|
name | Description (shows in output) |
hosts | Target hosts/groups from inventory |
become | Privilege escalation (sudo) |
vars | Variables for this play |
tasks | Ordered list of actions |
handlers | Tasks triggered by notify |
roles | Include reusable role packages |
gather_facts | Collect host info (default: true) |
Variables
---
- name: Deploy application
hosts: appservers
become: true
vars:
app_name: myapp
app_port: 8080
app_user: deploy
packages:
- nginx
- redis
- python3
vars_files:
- vars/common.yml
- vars/secrets.yml # Can be vault-encrypted
tasks:
- name: Install packages
ansible.builtin.apt:
name: "{{ packages }}"
state: present
- name: Create app user
ansible.builtin.user:
name: "{{ app_user }}"
- name: Show config
ansible.builtin.debug:
msg: "{{ app_name }} will run on port {{ app_port }}"
Complete Example: Web Server Setup
---
- name: Set up web server
hosts: webservers
become: true
vars:
domain: example.com
document_root: /var/www/html
http_port: 80
tasks:
# 1. Install packages
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install web server packages
ansible.builtin.apt:
name:
- nginx
- certbot
- python3-certbot-nginx
state: present
# 2. Configure
- name: Create document root
ansible.builtin.file:
path: "{{ document_root }}"
state: directory
owner: www-data
group: www-data
mode: "0755"
- name: Deploy nginx config
ansible.builtin.template:
src: templates/nginx-site.conf.j2
dest: "/etc/nginx/sites-available/{{ domain }}"
validate: "nginx -t -c %s"
notify: Reload nginx
- name: Enable site
ansible.builtin.file:
src: "/etc/nginx/sites-available/{{ domain }}"
dest: "/etc/nginx/sites-enabled/{{ domain }}"
state: link
notify: Reload nginx
- name: Deploy index page
ansible.builtin.copy:
content: |
<!DOCTYPE html>
<html>
<body><h1>Welcome to {{ domain }}</h1></body>
</html>
dest: "{{ document_root }}/index.html"
owner: www-data
mode: "0644"
# 3. Service management
- name: Ensure nginx is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
# 4. Firewall
- name: Allow HTTP
community.general.ufw:
rule: allow
port: "{{ http_port }}"
proto: tcp
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
Run a Playbook
# Basic run
ansible-playbook -i inventory/hosts site.yml
# With extra variables
ansible-playbook site.yml -e "env=production app_version=2.0"
# Limit to specific hosts
ansible-playbook site.yml --limit web-01
# Check mode (dry run — no changes)
ansible-playbook site.yml --check
# Diff mode (show what would change)
ansible-playbook site.yml --check --diff
# Step through tasks one at a time
ansible-playbook site.yml --step
# Start at a specific task
ansible-playbook site.yml --start-at-task "Deploy nginx config"
# List all tasks without running
ansible-playbook site.yml --list-tasks
# Verbose output
ansible-playbook site.yml -v # Some detail
ansible-playbook site.yml -vv # More detail
ansible-playbook site.yml -vvv # Full detail (SSH debug)
Tags
Run only specific tasks:
tasks:
- name: Install packages
ansible.builtin.apt:
name: [nginx, redis]
tags: [install, packages]
- name: Deploy config
ansible.builtin.template:
src: app.conf.j2
dest: /etc/app.conf
tags: [config, deploy]
- name: Start services
ansible.builtin.service:
name: nginx
state: started
tags: [services]
# Run only tasks tagged 'config'
ansible-playbook site.yml --tags config
# Run everything EXCEPT 'install'
ansible-playbook site.yml --skip-tags install
# Multiple tags
ansible-playbook site.yml --tags "config,services"
Multi-Play Playbook
---
# Play 1: Common setup on all hosts
- name: Common configuration
hosts: all
become: true
tasks:
- name: Update packages
ansible.builtin.apt:
upgrade: safe
update_cache: true
- name: Install common tools
ansible.builtin.apt:
name: [vim, curl, htop, git]
- name: Set timezone
community.general.timezone:
name: UTC
# Play 2: Web servers only
- name: Web server setup
hosts: webservers
become: true
roles:
- nginx
- certbot
# Play 3: Database servers only
- name: Database setup
hosts: dbservers
become: true
roles:
- role: postgresql
vars:
postgresql_version: 16
# Play 4: Post-deploy verification
- name: Verify deployment
hosts: all
gather_facts: false
tasks:
- name: Check connectivity
ansible.builtin.ping:
Error Handling
tasks:
# Ignore errors on a task
- name: Check optional service
ansible.builtin.command: systemctl status optional-svc
ignore_errors: true
# Block with rescue (try/catch)
- name: Deploy with rollback
block:
- name: Deploy new version
ansible.builtin.copy:
src: app-v2.tar.gz
dest: /opt/app/
- name: Restart app
ansible.builtin.service:
name: myapp
state: restarted
rescue:
- name: Rollback to previous version
ansible.builtin.copy:
src: app-v1.tar.gz
dest: /opt/app/
- name: Restart with old version
ansible.builtin.service:
name: myapp
state: restarted
always:
- name: Send notification
ansible.builtin.debug:
msg: "Deployment {{ 'failed — rolled back' if ansible_failed_task is defined else 'succeeded' }}"
Best Practices
- Name every task — clear names make output readable and debugging easy
- Use FQCN —
ansible.builtin.aptnot justapt - Use
becomeat play level — not on every task - Keep playbooks small — use roles for reusable components
- Use
--checkfirst — dry run before applying changes - Use handlers — don't restart services unconditionally
- Use variables — never hardcode values that might change
- Use tags — allow running subsets of tasks
Troubleshooting
"ERROR! 'tasks' is not a valid attribute for a Play"
Indentation error — YAML is whitespace-sensitive:
# WRONG
- name: My play
hosts: all
tasks: # ← wrong indentation
- name: ...
# RIGHT
- name: My play
hosts: all
tasks: # ← indented under the play
- name: ...
Task Always Shows "changed"
Use changed_when for commands:
- ansible.builtin.command: /opt/check-status.sh
changed_when: false # Read-only command
Related Articles
- How to Install Ansible
- Ansible Inventory Guide
- Ansible Roles Guide
- Ansible Handlers
- Ansible Variables Guide
- Ansible when Conditional
Conclusion
Playbooks are Ansible's automation scripts — YAML files that describe your desired infrastructure state. Start simple (one play, a few tasks), then grow into multi-play playbooks with roles, variables, handlers, and error handling. Always use --check for dry runs before applying changes. Name every task clearly, use FQCN module names, and keep playbooks focused — break large ones into roles for reusability.