Best Ansible Playbook Examples for Beginners
Best Ansible Playbook Examples for Beginners
Manual server configuration breaks the moment you need to manage more than three servers. You SSH into each server, run installation commands, edit configuration files, restart services, and document what you did (if you remember). Then a server fails, you provision a replacement, and you can't remember all the steps. Or worse, you remember most steps but miss one critical configuration, and the new server behaves differently from the old ones.
Ansible solves this by letting you write server configuration as YAML files called playbooks. Instead of SSHing into servers and running commands manually, you run one ansible-playbook command that connects to all servers simultaneously, executes your defined tasks, and ensures every server reaches the same configuration state. This article provides working playbook examples for common server management tasks, from basic package installation through complete application deployments.
You'll learn Ansible's core concepts (inventory, modules, tasks, playbooks), see practical examples you can run immediately, and understand the patterns that make playbooks maintainable as your infrastructure grows. These aren't minimal "hello world" examples—they're production-ready configurations handling real-world scenarios.
Why Ansible for Configuration Management
Configuration management tools solve the problem of keeping servers in a known, consistent state. Ansible's specific advantages come from its agentless architecture and push-based model. Unlike tools that require installing agents on every server, Ansible only needs SSH access. Your control machine (laptop or CI server) connects via SSH and executes configuration commands. No agents to update, no version drift between control plane and managed nodes.
The push model means you run playbooks when you want changes applied, not on a schedule. Chef and Puppet use pull models where agents check for configuration updates every 30 minutes. With Ansible, you make a configuration change, run the playbook, and see results immediately. For emergency fixes or one-off tasks, this directness eliminates waiting for the next check-in interval.
Ansible's language is YAML—declarative, human-readable, and version-controllable. You describe desired state (nginx installed, configuration file present, service running), and Ansible determines the steps needed. Running the same playbook twice produces the same result because Ansible only makes changes when current state differs from desired state. This idempotency means playbooks are safe to run repeatedly.
Ansible Core Concepts
Ansible operates on four foundational concepts: inventory defines which servers to manage, modules are the actions Ansible can perform, tasks combine modules with parameters, and playbooks organize tasks into reusable automation.
Inventory: Defining Managed Hosts
# inventory.ini
[webservers]
web1.example.com
web2.example.com
web3.example.com
[databases]
db1.example.com
db2.example.com
[production:children]
webservers
databases
[all:vars]
ansible_user=ubuntu
ansible_ssh_private_key_file=~/.ssh/production.pem
Inventory files list servers organized into groups. The [webservers] group contains three web servers, [databases] contains two database servers. The [production:children] group combines both into a higher-level grouping. The [all:vars] section sets variables that apply to all hosts—SSH username and private key location.
Modules: Ansible's Building Blocks
Modules are Ansible's library of actions. The apt module installs packages on Debian/Ubuntu systems, the yum module handles Red Hat/CentOS, the file module manages files and directories, the service module controls systemd/init services. Ansible includes hundreds of built-in modules covering most infrastructure tasks:
| Module | Purpose | Example Use |
|---|---|---|
| apt/yum | Package management | Install nginx, update all packages |
| file | File/directory operations | Create directories, set permissions |
| copy/template | File deployment | Deploy config files, app code |
| service/systemd | Service management | Start/stop/restart services |
| user/group | User management | Create users, manage SSH keys |
| git | Git operations | Clone repos, checkout branches |
Example 1: Basic Web Server Setup
This playbook installs nginx, deploys a basic configuration, and ensures the service is running. It's the foundation for most web server automation—install packages, configure, enable service.
Complete Playbook
# webserver.yml
---
- name: Configure web servers
hosts: webservers
become: yes
tasks:
- name: Update apt cache
apt:
update_cache: yes
cache_valid_time: 3600
- name: Install nginx
apt:
name: nginx
state: present
- name: Create web root directory
file:
path: /var/www/myapp
state: directory
owner: www-data
group: www-data
mode: '0755'
- name: Deploy nginx configuration
copy:
content: |
server {
listen 80;
server_name {{ inventory_hostname }};
location / {
root /var/www/myapp;
index index.html;
}
}
dest: /etc/nginx/sites-available/myapp
owner: root
group: root
mode: '0644'
notify: Reload nginx
- name: Enable site
file:
src: /etc/nginx/sites-available/myapp
dest: /etc/nginx/sites-enabled/myapp
state: link
notify: Reload nginx
- name: Deploy index.html
copy:
content: |
{{ inventory_hostname }}
Server: {{ inventory_hostname }}
Configured with Ansible
dest: /var/www/myapp/index.html
owner: www-data
group: www-data
mode: '0644'
- name: Ensure nginx is running
service:
name: nginx
state: started
enabled: yes
handlers:
- name: Reload nginx
service:
name: nginx
state: reloaded
Running the Playbook
# Check syntax
ansible-playbook --syntax-check webserver.yml
# Run in check mode (dry run, no changes)
ansible-playbook --check webserver.yml -i inventory.ini
# Execute the playbook
ansible-playbook webserver.yml -i inventory.ini
# Output shows task execution status
PLAY [Configure web servers] ***********************************
TASK [Update apt cache] ****************************************
ok: [web1.example.com]
ok: [web2.example.com]
TASK [Install nginx] *******************************************
changed: [web1.example.com]
changed: [web2.example.com]
PLAY RECAP *****************************************************
web1.example.com : ok=8 changed=6
web2.example.com : ok=8 changed=6
The become: yes directive runs tasks with sudo privileges (required for package installation and system configuration). The notify directive triggers handlers only when the task makes changes—nginx reloads only if configuration changes, not on every playbook run. This efficiency prevents unnecessary service restarts.
Example 2: User and SSH Key Management
Managing user accounts and SSH keys manually across multiple servers is tedious and error-prone. This playbook creates user accounts, deploys SSH public keys, and configures sudo access—common tasks when onboarding new team members or provisioning new servers.
# users.yml
---
- name: Manage user accounts
hosts: all
become: yes
vars:
users:
- name: alice
ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQ... [email protected]"
sudo: yes
- name: bob
ssh_key: "ssh-rsa AAAAB3NzaC1yc2EAAAADAQABAAACAQDn... [email protected]"
sudo: no
tasks:
- name: Create user accounts
user:
name: "{{ item.name }}"
state: present
shell: /bin/bash
create_home: yes
loop: "{{ users }}"
- name: Deploy SSH authorized keys
authorized_key:
user: "{{ item.name }}"
state: present
key: "{{ item.ssh_key }}"
exclusive: yes
loop: "{{ users }}"
- name: Configure sudo access
copy:
content: "{{ item.name }} ALL=(ALL) NOPASSWD:ALL"
dest: "/etc/sudoers.d/{{ item.name }}"
mode: '0440'
validate: 'visudo -cf %s'
loop: "{{ users }}"
when: item.sudo
- name: Create .ssh directory
file:
path: "/home/{{ item.name }}/.ssh"
state: directory
owner: "{{ item.name }}"
group: "{{ item.name }}"
mode: '0700'
loop: "{{ users }}"
- name: Disable password authentication
lineinfile:
path: /etc/ssh/sshd_config
regexp: '^PasswordAuthentication'
line: 'PasswordAuthentication no'
state: present
notify: Restart sshd
handlers:
- name: Restart sshd
service:
name: sshd
state: restarted
The vars section defines users as a list of dictionaries, each containing name, ssh_key, and sudo status. The loop: "{{ users }}" directive iterates tasks over this list, creating accounts and deploying keys for each user. The when: item.sudo conditional runs the sudo configuration task only for users where sudo is true. The validate parameter in the sudoers copy task prevents invalid sudoers files from being deployed—Ansible tests the file with visudo before committing it.
Example 3: Database Installation and Configuration
Database setup involves package installation, service configuration, security hardening, and initial database creation. This playbook handles PostgreSQL installation with proper security settings—changing default passwords, restricting network access, and configuring authentication.
# database.yml
---
- name: Configure PostgreSQL database servers
hosts: databases
become: yes
vars:
postgres_version: "14"
postgres_password: "{{ vault_postgres_password }}"
database_name: "production_db"
database_user: "app_user"
database_password: "{{ vault_database_password }}"
tasks:
- name: Install PostgreSQL
apt:
name:
- postgresql-{{ postgres_version }}
- postgresql-contrib
- python3-psycopg2
state: present
update_cache: yes
- name: Ensure PostgreSQL is running
service:
name: postgresql
state: started
enabled: yes
- name: Configure PostgreSQL to listen on all interfaces
lineinfile:
path: /etc/postgresql/{{ postgres_version }}/main/postgresql.conf
regexp: '^#?listen_addresses'
line: "listen_addresses = '*'"
state: present
notify: Restart PostgreSQL
- name: Configure client authentication
blockinfile:
path: /etc/postgresql/{{ postgres_version }}/main/pg_hba.conf
block: |
# Application server access
host {{ database_name }} {{ database_user }} 10.0.0.0/8 md5
marker: "# {mark} ANSIBLE MANAGED BLOCK"
notify: Restart PostgreSQL
- name: Set postgres user password
postgresql_user:
name: postgres
password: "{{ postgres_password }}"
encrypted: yes
become_user: postgres
- name: Create application database
postgresql_db:
name: "{{ database_name }}"
encoding: UTF8
lc_collate: en_US.UTF-8
lc_ctype: en_US.UTF-8
become_user: postgres
- name: Create application user
postgresql_user:
name: "{{ database_user }}"
password: "{{ database_password }}"
encrypted: yes
db: "{{ database_name }}"
priv: ALL
become_user: postgres
- name: Grant schema privileges
postgresql_privs:
database: "{{ database_name }}"
type: schema
objs: public
privs: ALL
role: "{{ database_user }}"
become_user: postgres
handlers:
- name: Restart PostgreSQL
service:
name: postgresql
state: restarted
Managing Secrets with Ansible Vault
# Create encrypted vars file
ansible-vault create vars/secrets.yml
# Enter vault password, then edit file
---
vault_postgres_password: "secure-random-password-here"
vault_database_password: "another-secure-password"
# Run playbook with vault
ansible-playbook database.yml -i inventory.ini --ask-vault-pass
# Or use vault password file
echo "your-vault-password" > .vault_pass
ansible-playbook database.yml -i inventory.ini --vault-password-file .vault_pass
Ansible Vault encrypts sensitive variables, preventing passwords from existing in plaintext in your playbooks or version control. The vault_postgres_password variable references an encrypted value that Ansible decrypts at runtime. This pattern lets you safely commit playbooks to git without exposing credentials.
Example 4: Application Deployment with Git
Application deployment typically involves pulling code from git, installing dependencies, configuring environment variables, and restarting the application service. This playbook handles a Node.js application deployment with all these steps.
# deploy.yml
---
- name: Deploy Node.js application
hosts: webservers
become: yes
vars:
app_name: "myapp"
app_user: "appuser"
app_directory: "/opt/{{ app_name }}"
git_repo: "https://github.com/company/myapp.git"
git_branch: "main"
node_version: "20"
tasks:
- name: Install Node.js
shell: |
curl -fsSL https://deb.nodesource.com/setup_{{ node_version }}.x | bash -
apt-get install -y nodejs
args:
creates: /usr/bin/node
- name: Create application user
user:
name: "{{ app_user }}"
system: yes
shell: /bin/bash
home: "{{ app_directory }}"
- name: Create application directory
file:
path: "{{ app_directory }}"
state: directory
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0755'
- name: Deploy application from Git
git:
repo: "{{ git_repo }}"
dest: "{{ app_directory }}"
version: "{{ git_branch }}"
force: yes
become_user: "{{ app_user }}"
notify: Restart application
- name: Install Node.js dependencies
npm:
path: "{{ app_directory }}"
production: yes
become_user: "{{ app_user }}"
notify: Restart application
- name: Create environment file
copy:
content: |
NODE_ENV=production
PORT=3000
DATABASE_URL={{ database_url }}
dest: "{{ app_directory }}/.env"
owner: "{{ app_user }}"
group: "{{ app_user }}"
mode: '0600'
notify: Restart application
- name: Create systemd service file
copy:
content: |
[Unit]
Description={{ app_name }} application
After=network.target
[Service]
Type=simple
User={{ app_user }}
WorkingDirectory={{ app_directory }}
ExecStart=/usr/bin/node {{ app_directory }}/server.js
Restart=always
RestartSec=10
StandardOutput=journal
StandardError=journal
EnvironmentFile={{ app_directory }}/.env
[Install]
WantedBy=multi-user.target
dest: "/etc/systemd/system/{{ app_name }}.service"
mode: '0644'
notify:
- Reload systemd
- Restart application
- name: Enable and start application service
service:
name: "{{ app_name }}"
enabled: yes
state: started
handlers:
- name: Reload systemd
systemd:
daemon_reload: yes
- name: Restart application
service:
name: "{{ app_name }}"
state: restarted
The git module clones the repository and checks out the specified branch. The force: yes parameter discards local changes (important when deploying over previous versions). The npm module installs dependencies, with production: yes omitting devDependencies. Handlers ensure the application restarts only when code, dependencies, or configuration change—not on every playbook run.
Example 5: Firewall Configuration with UFW
Firewall rules protect servers from unauthorized access. This playbook configures UFW (Uncomplicated Firewall) with rules specific to each server role—web servers allow HTTP/HTTPS, database servers allow PostgreSQL from application servers only.
# firewall.yml
---
- name: Configure firewall on all servers
hosts: all
become: yes
tasks:
- name: Install UFW
apt:
name: ufw
state: present
- name: Set default incoming policy to deny
ufw:
direction: incoming
policy: deny
- name: Set default outgoing policy to allow
ufw:
direction: outgoing
policy: allow
- name: Allow SSH from anywhere
ufw:
rule: allow
port: '22'
proto: tcp
comment: 'SSH access'
- name: Enable UFW
ufw:
state: enabled
- name: Configure web server firewall rules
hosts: webservers
become: yes
tasks:
- name: Allow HTTP
ufw:
rule: allow
port: '80'
proto: tcp
comment: 'HTTP access'
- name: Allow HTTPS
ufw:
rule: allow
port: '443'
proto: tcp
comment: 'HTTPS access'
- name: Configure database server firewall rules
hosts: databases
become: yes
tasks:
- name: Allow PostgreSQL from web servers
ufw:
rule: allow
port: '5432'
proto: tcp
from_ip: "{{ hostvars[item]['ansible_default_ipv4']['address'] }}"
comment: "PostgreSQL from {{ item }}"
loop: "{{ groups['webservers'] }}"
- name: Deny PostgreSQL from everywhere else
ufw:
rule: deny
port: '5432'
proto: tcp
This playbook uses multiple plays (the "---" separated sections). The first play runs against all hosts, setting baseline firewall rules. Subsequent plays add role-specific rules. The database firewall configuration demonstrates Ansible's host introspection—it queries IP addresses of web servers using hostvars and creates allow rules for each one. This dynamic rule generation prevents hardcoding IP addresses in playbooks.
Example 6: System Updates and Patching
Regular system updates are critical for security but disruptive if done manually. This playbook updates packages, handles kernel updates requiring reboots, and can be scheduled via cron for automated patching.
# system-update.yml
---
- name: System updates and patching
hosts: all
become: yes
serial: 1
tasks:
- name: Update apt cache
apt:
update_cache: yes
- name: Upgrade all packages
apt:
upgrade: dist
autoremove: yes
autoclean: yes
register: upgrade_result
- name: Check if reboot is required
stat:
path: /var/run/reboot-required
register: reboot_required
- name: Display packages that were upgraded
debug:
msg: "{{ upgrade_result.stdout_lines }}"
when: upgrade_result.changed
- name: Reboot server if required
reboot:
msg: "Rebooting for kernel updates"
connect_timeout: 5
reboot_timeout: 300
pre_reboot_delay: 0
post_reboot_delay: 30
test_command: uptime
when: reboot_required.stat.exists
- name: Wait for server to come back online
wait_for_connection:
connect_timeout: 20
sleep: 5
delay: 5
timeout: 300
when: reboot_required.stat.exists
- name: Verify services are running
service:
name: "{{ item }}"
state: started
loop:
- nginx
- postgresql
when: reboot_required.stat.exists
ignore_errors: yes
The serial: 1 directive processes hosts one at a time rather than all simultaneously. This prevents updating all servers at once (which would cause downtime). The playbook updates packages, checks if a reboot is required (/var/run/reboot-required file exists on Ubuntu after kernel updates), reboots if needed, waits for the server to come back, and verifies critical services restarted. This automation makes patching routine rather than an emergency fire drill.
Playbook Organization Patterns
As playbooks grow, organizing them properly prevents the single-file maintenance nightmare. The standard pattern uses roles—reusable components containing tasks, handlers, files, templates, and variables organized in a consistent directory structure.
Converting Playbooks to Roles
# Directory structure
roles/
├── nginx/
│ ├── tasks/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ ├── templates/
│ │ └── nginx.conf.j2
│ ├── files/
│ │ └── index.html
│ └── defaults/
│ └── main.yml
├── postgresql/
│ ├── tasks/
│ │ └── main.yml
│ ├── handlers/
│ │ └── main.yml
│ └── vars/
│ └── main.yml
└── common/
├── tasks/
│ └── main.yml
└── handlers/
└── main.yml
# Main playbook using roles
# site.yml
---
- name: Configure all servers
hosts: all
become: yes
roles:
- common
- name: Configure web servers
hosts: webservers
become: yes
roles:
- nginx
- name: Configure database servers
hosts: databases
become: yes
roles:
- postgresql
Roles encapsulate related tasks, files, and variables. The nginx role contains all nginx-specific configuration, making it reusable across multiple playbooks. Variables in roles/nginx/defaults/main.yml set defaults that can be overridden per environment. Templates in roles/nginx/templates/ use Jinja2 for dynamic configuration generation.
Testing Playbooks
Running playbooks against production without testing is how infrastructure incidents happen. Ansible provides several testing mechanisms: check mode (dry run), syntax checking, molecule (automated testing framework), and staging environments.
Testing Workflow
# 1. Syntax check
ansible-playbook --syntax-check playbook.yml
# 2. Check mode (shows what would change, no modifications)
ansible-playbook --check playbook.yml -i inventory.ini
# 3. Diff mode (shows exact changes to files)
ansible-playbook --check --diff playbook.yml -i inventory.ini
# 4. Run against staging environment first
ansible-playbook playbook.yml -i staging-inventory.ini
# 5. After validation, run against production
ansible-playbook playbook.yml -i production-inventory.ini
Check mode simulates execution, showing which tasks would change without actually making changes. This catches obvious errors but isn't perfect—modules that depend on earlier tasks might show incorrect results because the earlier tasks didn't actually execute. The diff mode shows line-by-line changes to configuration files before applying them, useful for reviewing exactly what will change.
Common Ansible Patterns
Certain patterns appear repeatedly in production playbooks. These solve common problems around conditionals, error handling, and dynamic configuration.
Conditional Execution
# Run task only on Ubuntu
- name: Install package
apt:
name: nginx
when: ansible_distribution == "Ubuntu"
# Run task only in production
- name: Enable monitoring
service:
name: datadog-agent
state: started
when: environment == "production"
# Combined conditions
- name: Configure SSL
template:
src: ssl.conf.j2
dest: /etc/nginx/ssl.conf
when:
- environment == "production"
- enable_ssl is defined
- enable_ssl | bool
Error Handling
# Ignore errors and continue
- name: Try to stop service (might not exist)
service:
name: old-service
state: stopped
ignore_errors: yes
# Fail if condition not met
- name: Check disk space
shell: df -h / | awk 'NR==2 {print $5}' | sed 's/%//'
register: disk_usage
failed_when: disk_usage.stdout | int > 90
# Retry tasks that might fail temporarily
- name: Download large file
get_url:
url: https://example.com/largefile.tar.gz
dest: /tmp/largefile.tar.gz
retries: 3
delay: 10
Dynamic Variables with Templates
# templates/nginx.conf.j2
server {
listen 80;
server_name {{ server_name }};
{% if enable_ssl %}
listen 443 ssl;
ssl_certificate {{ ssl_cert_path }};
ssl_certificate_key {{ ssl_key_path }};
{% endif %}
location / {
proxy_pass http://{{ backend_host }}:{{ backend_port }};
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
}
}
# Using the template
- name: Deploy nginx configuration
template:
src: nginx.conf.j2
dest: /etc/nginx/sites-available/myapp
notify: Reload nginx
FAQ
What's the difference between Ansible and Terraform?
Terraform provisions infrastructure (VPCs, instances, databases), while Ansible configures software on that infrastructure (install packages, deploy apps, configure services). They're complementary—use Terraform to create servers, then Ansible to configure them. Some overlap exists (Terraform can run provisioners, Ansible has cloud modules), but each tool excels at its primary purpose.
Do I need to install Ansible on every server?
No. Ansible is agentless and only needs SSH access to managed servers. Install Ansible on one control machine (your laptop, a CI server), and it connects to managed servers via SSH. The managed servers only need Python (pre-installed on most Linux distributions) and SSH server running.
How do I handle secrets in Ansible?
Use Ansible Vault to encrypt sensitive variables (passwords, API keys). Create encrypted variable files with ansible-vault create, then reference those variables in playbooks. Run playbooks with --ask-vault-pass or --vault-password-file to decrypt at runtime. Never commit plaintext secrets to version control.
Can Ansible manage Windows servers?
Yes, through WinRM instead of SSH. Install pywinrm on the control machine, configure WinRM on Windows servers, and use Windows-specific modules (win_package, win_service, win_file). Most core Ansible concepts apply, but module names differ because Windows APIs differ from Linux.
What's the difference between copy and template modules?
The copy module deploys files as-is without modification. The template module processes files through Jinja2 templating engine, replacing variables with actual values. Use copy for static files (images, binaries), template for configuration files that need variable substitution.
How do I run playbooks automatically on a schedule?
Add ansible-playbook commands to cron on your control machine. For example, daily system updates: 0 2 * * * /usr/bin/ansible-playbook /path/to/system-update.yml -i inventory.ini. For complex scheduling, use a CI/CD system (Jenkins, GitLab CI) that can run playbooks on schedules or git pushes.
Should I use handlers or notified tasks?
Handlers are deferred actions that run once at the end of a play, even if multiple tasks trigger them. This prevents unnecessary service restarts (five tasks modify nginx config, nginx restarts once, not five times). Use handlers for service restarts, reloads, and other actions that should happen only if something changed.
How do I debug playbook failures?
Add -v, -vv, or -vvv flags for increasing verbosity levels. Use the debug module to print variable values. Run playbooks with --start-at-task to resume from a specific task after fixing issues. Check ansible.log (if logging is enabled) for detailed execution information including SSH commands and module output.
Can multiple people run playbooks simultaneously?
Yes, but be cautious. Ansible doesn't lock hosts—two people running playbooks against the same hosts simultaneously can cause race conditions or configuration conflicts. For production, run playbooks through CI/CD pipelines that serialize execution, or use Ansible Tower/AWX which provides execution locking and scheduling.
How do I roll back changes if a playbook breaks something?
Ansible doesn't have built-in rollback. Your rollback is running the previous version of your playbook (via git revert). This is why version controlling playbooks is critical. For application deployments, implement blue-green or symlink-based patterns where you deploy to a new directory and switch atomically, keeping the old version for quick rollback.
Conclusion
Ansible playbooks transform manual server configuration into automated, reproducible processes. The examples in this article—web server setup, user management, database installation, application deployment, firewall configuration, and system updates—cover the most common infrastructure automation needs. Each playbook is idempotent (safe to run multiple times), maintainable (organized clearly with roles), and production-ready (handles errors and notifications properly).
Start with one manual task you do frequently—deploying application code, adding user accounts, updating packages—and convert it to an Ansible playbook. Test it in a safe environment, refine it based on what breaks, then expand to more complex scenarios. The investment in learning Ansible syntax pays off quickly because playbooks become both your automation and your documentation.
The goal isn't to automate everything immediately. It's to stop manually SSHing into servers for routine tasks. Every manual change you make is a potential inconsistency waiting to cause production issues. Playbooks prevent that by ensuring every server reaches the same state every time.