Ansible Playbooks for Configuration Management
A hands-on guide to writing Ansible playbooks that configure servers, deploy apps, and enforce desired state across your fleet.
What you'll learn
- ✓How Ansible playbooks work (inventory, plays, tasks)
- ✓Writing idempotent tasks with built-in modules
- ✓Using roles to organize reusable configuration
- ✓Variables, templates, and handlers for real-world setups
Prerequisites
- •Basic Linux command-line skills
- •SSH access to at least one test server
What Ansible Does
Ansible is an agentless configuration management tool. You write YAML files that describe the desired state of your servers, and Ansible connects over SSH to make reality match that description. No daemon to install, no certificate authority to manage, no pull-based polling loop. You run a command from your laptop and things happen on remote machines.
The core unit is a playbook: a YAML file containing one or more plays. Each play targets a group of hosts and runs a sequence of tasks. Each task calls a module, which is a small program that knows how to manage a specific thing (a package, a file, a service, a user).
Inventory
Before you write a playbook, Ansible needs to know which machines to target. The simplest inventory is an INI file:
# inventory/hosts
[webservers]
web1.example.com
web2.example.com
[databases]
db1.example.com
[all:vars]
ansible_user=deploy
ansible_python_interpreter=/usr/bin/python3
You can also use YAML format or dynamic inventory scripts that pull hosts from AWS, GCP, or a CMDB. For now, a static file is fine.
Your First Playbook
# playbooks/setup-webserver.yml
---
- name: Configure web servers
hosts: webservers
become: true
vars:
app_port: 8080
nginx_worker_processes: auto
tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
- name: Install required packages
ansible.builtin.apt:
name:
- nginx
- curl
- htop
- unzip
state: present
- name: Create application directory
ansible.builtin.file:
path: /opt/myapp
state: directory
owner: deploy
group: deploy
mode: "0755"
- name: Copy nginx configuration
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/sites-available/myapp.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Enable site
ansible.builtin.file:
src: /etc/nginx/sites-available/myapp.conf
dest: /etc/nginx/sites-enabled/myapp.conf
state: link
notify: Reload nginx
- name: Ensure nginx is running
ansible.builtin.systemd:
name: nginx
state: started
enabled: true
handlers:
- name: Reload nginx
ansible.builtin.systemd:
name: nginx
state: reloaded
Run it with:
ansible-playbook -i inventory/hosts playbooks/setup-webserver.yml
Key points: become: true runs tasks as root. The template module renders Jinja2 templates. The notify directive triggers a handler, but only if the task actually changed something. Handlers run once at the end of the play, not after every notification.
Templates
Templates use Jinja2 syntax to inject variables into configuration files:
# templates/nginx.conf.j2
upstream app {
server 127.0.0.1:{{ app_port }};
}
server {
listen 80;
server_name {{ inventory_hostname }};
location / {
proxy_pass http://app;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
}
access_log /var/log/nginx/{{ inventory_hostname }}_access.log;
}
The variable inventory_hostname is automatically set by Ansible to the hostname from the inventory file. Your own variables like app_port come from the vars section, group variables, or host variables.
Variables and Precedence
Ansible has over twenty levels of variable precedence. In practice, you only need to care about a few:
inventory/
group_vars/
all.yml # applies to every host
webservers.yml # applies to webservers group
host_vars/
web1.example.com.yml # applies to one specific host
# inventory/group_vars/webservers.yml
app_port: 8080
max_upload_size: 50m
deploy_user: deploy
# inventory/group_vars/all.yml
ntp_servers:
- 0.pool.ntp.org
- 1.pool.ntp.org
timezone: UTC
The rule of thumb: more specific wins. Host vars override group vars, which override all.yml. Command-line --extra-vars override everything.
Roles
As playbooks grow, you extract reusable chunks into roles. A role is a directory with a conventional structure:
roles/
nginx/
tasks/
main.yml
templates/
nginx.conf.j2
handlers/
main.yml
defaults/
main.yml
vars/
main.yml
meta/
main.yml
The tasks file contains the task list without the play-level wrapper:
# roles/nginx/tasks/main.yml
---
- name: Install nginx
ansible.builtin.apt:
name: nginx
state: present
- name: Deploy configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
- name: Ensure nginx is running
ansible.builtin.systemd:
name: nginx
state: started
enabled: true
# roles/nginx/defaults/main.yml
---
nginx_worker_processes: auto
nginx_worker_connections: 1024
Then your playbook becomes clean:
# playbooks/setup-webserver.yml
---
- name: Configure web servers
hosts: webservers
become: true
roles:
- nginx
- app_deploy
- monitoring_agent
Idempotency
Every task should be safe to run multiple times. The apt module checks if a package is already installed before installing it. The template module compares checksums before writing a file. The systemd module checks the current service state before acting.
If you must run a raw shell command, use creates or removes to make it idempotent:
- name: Extract application archive
ansible.builtin.unarchive:
src: /tmp/myapp-v2.3.tar.gz
dest: /opt/myapp
remote_src: true
creates: /opt/myapp/v2.3/app.jar
The creates parameter tells Ansible to skip the task if that path already exists. Without it, the archive would be extracted on every run.
Conditionals and Loops
- name: Install packages for Debian family
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop:
- nginx
- certbot
- python3-certbot-nginx
when: ansible_os_family == "Debian"
- name: Install packages for RedHat family
ansible.builtin.dnf:
name: "{{ item }}"
state: present
loop:
- nginx
- certbot
- python3-certbot-nginx
when: ansible_os_family == "RedHat"
Ansible gathers facts about each host automatically (OS family, IP addresses, memory, disk). You can use these facts in when conditions and templates.
Error Handling
By default, Ansible stops executing on a host if any task fails. You can change this behavior:
- name: Check if legacy config exists
ansible.builtin.stat:
path: /etc/old-app/config.yml
register: legacy_config
- name: Migrate legacy config
ansible.builtin.command:
cmd: /usr/local/bin/migrate-config.sh
when: legacy_config.stat.exists
- name: Attempt graceful reload
ansible.builtin.systemd:
name: myapp
state: reloaded
register: reload_result
failed_when: false
- name: Force restart if reload failed
ansible.builtin.systemd:
name: myapp
state: restarted
when: reload_result is failed
The register keyword captures the result of a task for use in later conditions. The failed_when: false prevents a failure from stopping execution so you can handle it yourself.
Putting It Together
A realistic project structure looks like this:
ansible/
ansible.cfg
inventory/
production/
hosts
group_vars/
all.yml
webservers.yml
staging/
hosts
group_vars/
all.yml
playbooks/
site.yml
deploy.yml
roles/
common/
nginx/
app/
monitoring/
You run the full setup with ansible-playbook -i inventory/production playbooks/site.yml and deploy updates with ansible-playbook -i inventory/production playbooks/deploy.yml --tags deploy.
Start with a single playbook that configures one server. Extract repeated blocks into roles as patterns emerge. Add group variables when you need different values per environment. Ansible rewards incremental adoption: you do not need to model your entire infrastructure on day one.
Related articles
- DevOps Ansible vs Terraform: Configuration vs Infrastructure
Compare Ansible and Terraform for DevOps automation. Learn when to use each tool, how they differ in approach, and how to combine them effectively.
- DevOps Configuration Management: Ansible, Chef, and Puppet
Compare Ansible, Chef, and Puppet for configuration management. Learn how each tool works with practical examples for server provisioning and application deployment.
- DevOps Pulumi for Infrastructure as Code with Real Programming Languages
Learn how Pulumi lets you define cloud infrastructure using TypeScript, Python, or Go instead of domain-specific languages.
- DevOps Platform Engineering Principles for DevOps Teams
Understand platform engineering fundamentals: internal developer platforms, golden paths, self-service infrastructure, and measuring platform success.