Ansible Playbook for Server Configuration
How to write and run Ansible playbooks for provisioning, configuring, and managing servers with idempotent tasks, roles, and inventory files.
Overview
Ansible is agentless automation — no daemon on the target, just SSH and a YAML playbook. That one property is why it won the configuration-management war: provision a box once (with Terraform or by hand), then let a playbook describe what it should look like, idempotent and version-controlled.
Before Ansible, sysadmins logged into each server to install packages and restart services — slow, unrepeatable, unauditable. A playbook replaces that with a declarative description that configures hundreds of hosts in minutes and lives in git next to the app it deploys — which is also how it pairs with the CI/CD pipeline that runs it and the Docker images it ships.
When to Use
Reach for Ansible when the same change has to happen on more than one box. Provisioning a baseline (packages, users, SSH keys) across a fleet, pushing an app update to all web servers at once, keeping Nginx/Postgres/Redis configs identical on every node, or running a one-off command everywhere — restart after a security patch, say. And when the playbook needs a password, Ansible Vault encrypts it instead of committing it plaintext.
If you only have one server and no growth planned, a shell script is honestly simpler. The playbook starts paying off the moment a second host exists.
Skip it when the job isn’t configuration at all: building golden images belongs to Packer, immutable container images belong to your Dockerfile, and one-off ad-hoc commands (ansible webservers -m ping) don’t need a playbook file. Ansible also struggles where the target can’t take SSH — that’s what the cloud provider’s API or an agent-based tool is for.
Solution
A minimal working setup is three files: an inventory, the playbook, and a config file. This one installs Nginx, drops a config, and reloads the service only when the file actually changed:
# inventory.ini — the hosts the playbook manages
[webservers]
web1.example.com
web2.example.com ansible_user=deployer
[webservers:vars]
ansible_port=22
# site.yml — the playbook itself
- name: Configure web servers
hosts: webservers
become: yes
vars:
nginx_worker_processes: 4
tasks:
- name: Install Nginx
ansible.builtin.apt:
name: nginx
state: present
update_cache: yes
- name: Deploy nginx.conf
ansible.builtin.template:
src: templates/nginx.conf.j2
dest: /etc/nginx/nginx.conf
owner: root
group: root
mode: "0644"
notify: Reload nginx
- name: Ensure Nginx is running and enabled
ansible.builtin.service:
name: nginx
state: started
enabled: yes
handlers:
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
# ansible.cfg — project-level settings
[defaults]
inventory = inventory.ini
retry_files_enabled = False
host_key_checking = False
Run it:
# Dry run — shows what would change, changes nothing
ansible-playbook site.yml --check --diff
# Real run
ansible-playbook site.yml
# Against one host only
ansible-playbook site.yml --limit web1.example.com
Explanation
The play targets the webservers group from the inventory, and each task calls a module with a declared end state — state: present says “make sure this is installed,” not “run apt-get.” That’s what makes the second run a no-op: Ansible checks whether the system already matches before doing anything. The template task registers a notify on the Reload nginx handler, which fires once at the end of the play and only if the rendered file differed — no pointless reloads on every run.
become: yes at play level elevates to root for the whole run; tighten it to per-task become when a playbook mixes privileged and unprivileged work (the Common Mistakes section shows how). And the --check --diff pair is the habit that matters most: --check simulates, --diff shows the actual file changes, and together they’re the difference between a confident deploy and a prayer.
Two pieces of Ansible’s model are worth internalizing before you scale past one playbook. Facts are the per-host data Ansible gathers at the start of each play (OS family, IPs, memory) — ansible_os_family == "Debian" in a when: is a fact doing real work. Variable precedence decides which of the eight places you can define nginx_worker_processes actually wins: playbook vars beat inventory vars, group_vars beat host defaults, and extra_vars (-e) beat everything. When a playbook “ignores” your variable, it isn’t ignored — something with higher precedence is overriding it.
As a playbook grows past a screen of tasks, pull it apart into roles: a roles/nginx/ directory with tasks/, handlers/, templates/, and defaults/ that a play can consume as roles: [nginx]. That’s the unit Ansible Galaxy shares, and it’s also how a monorepo keeps ten playbooks from re-declaring the same web-server setup. Start flat, refactor into a role the second time you copy a task block — that ordering is deliberate, because premature role structure is its own kind of mess.
Common Mistakes
The mistakes repeat everywhere. Running a playbook without --check first — check mode shows what would change before anything does, and on production that’s not optional. Reaching for shell when a module exists: modules are idempotent and handle the edge cases your one-liner won’t. Forgetting become: yes and chasing a cryptic permission error for an hour. Hardcoding IPs in the inventory — they rot; use DNS or a dynamic inventory that stays current. And restarting Nginx on every task instead of notifying a handler — handlers fire once, at the end, when something actually changed.
- Skipping fact caching. Gathering facts is slow at fleet scale:
# ansible.cfg
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = ./.facts
fact_caching_timeout = 86400
- Running everything as root. Use
becomeselectively per task:
# Bad: everything runs as root
- hosts: all
become: yes
tasks:
- name: Clone repo as root
ansible.builtin.git:
repo: https://github.com/myorg/myapp.git
dest: /home/deployer/myapp
# Good: become only where needed
- hosts: all
tasks:
- name: Install packages
ansible.builtin.apt:
name: nginx
state: present
become: yes
- name: Clone repo as deployer
ansible.builtin.git:
repo: https://github.com/myorg/myapp.git
dest: /home/deployer/myapp
become: yes
become_user: deployer
- Not using
--difffor auditing. See exactly what changes on each run:
ansible-playbook site.yml --diff
What works
Playbooks should be boring — that’s the whole point. Make every task idempotent: the second run should change nothing, which means state: present over shell commands that blindly reinstall. Pull shared tasks into roles — that’s what makes them shareable through Galaxy or an internal repo instead of copy-paste. Everything lives in git — playbooks, inventories, variables — so changes get reviewed and rolled back. Run ansible-lint before shipping; it catches the mistakes that cost a deploy window. And encrypt secrets with Ansible Vault; a committed plaintext password is an incident waiting to happen.
One more habit worth the line: keep playbooks runnable from CI. A playbook that only works from one engineer’s laptop is a snowflake with extra steps — the --check --diff output in a pull request is the cheapest review surface you’ll ever get for infrastructure.
Performance Tips
Playbook speed is mostly about not doing work the host doesn’t need: every second saved per host multiplies across the fleet. Fact gathering, sequential barriers between tasks, and serial execution are the usual costs — the three tips below target exactly those.
- Run
strategy: freefor faster execution. Hosts stop waiting on each other:
- name: Fast parallel deployment
hosts: webservers
strategy: free
tasks:
- name: Deploy app
ansible.builtin.git:
repo: https://github.com/myorg/myapp.git
dest: /var/www/myapp
- Disable fact gathering when unused. Saves 2-5 seconds per host:
- name: Simple task without facts
hosts: webservers
gather_facts: no
tasks:
- name: Restart service
ansible.builtin.service:
name: nginx
state: restarted
- Use
asyncfor long-running tasks. Don’t block the playbook:
- name: Run slow backup
ansible.builtin.shell: pg_dump mydb > /tmp/backup.sql
async: 300
poll: 5
Companion files: the ansible-playbook companion repo has the three files above — inventory.ini, site.yml, ansible.cfg — ready to run.
Further Reading
- Ansible Documentation — Playbooks — the official guide
- Red Hat — Ansible Best Practices — tips from the source
Frequently Asked Questions
What is the advantage of Ansible over shell scripts?
Playbooks declare the end state instead of scripting the steps — run one twice and the second run changes nothing, while a shell script happily installs the same package again or errors out.
How do I manage secrets in Ansible?
Encrypt them with Ansible Vault, or pull them from an external secret manager (HashiCorp Vault, AWS Secrets Manager). The one rule that matters: no plaintext passwords in git.
What is an Ansible inventory?
The list of hosts Ansible manages, grouped however makes sense — web, db, cache. It can be a static file or a dynamic plugin that pulls the live fleet straight from your cloud provider.
How do I use a dynamic inventory with cloud providers?
Point ansible.cfg at a plugin file instead of a static inventory — the plugin queries the provider's API at run time:
# ansible.cfg
[defaults]
inventory = aws_ec2.yml
host_key_checking = False
retry_files_enabled = False
# aws_ec2.yml
plugin: aws_ec2
regions:
- us-east-1
- us-west-2
keyed_groups:
- prefix: tag
key: tags
host_filters:
- tag:Name=production-*
compose:
ansible_host: public_ip_address
ansible_user: "'ubuntu'"
# List dynamic inventory
ansible-inventory --list
# Graph view
ansible-inventory --graph
How do I run the same playbook across multiple environments?
Keep one playbook and separate the state: an environments/ directory with an inventory per environment, and group_vars for the values that differ:
ansible-playbook site.yml -i environments/production/inventory.ini
ansible-playbook site.yml -i environments/staging/inventory.ini
environments/
├── staging/
│ ├── inventory.ini
│ └── group_vars/webservers.yml
└── production/
├── inventory.ini
└── group_vars/webservers.yml
How do I run tasks conditionally or loop over items?
when: gates a task on a fact or variable; loop: repeats it over a list — the two cover most of what shell if/for used to do:
- name: Configure database servers
hosts: dbservers
become: yes
tasks:
- name: Install PostgreSQL on Debian
ansible.builtin.apt:
name: postgresql
state: present
when: ansible_os_family == "Debian"
- name: Install PostgreSQL on RHEL
ansible.builtin.yum:
name: postgresql-server
state: present
when: ansible_os_family == "RedHat"
- name: Create databases
ansible.builtin.postgresql_db:
name: "{{ item.name }}"
encoding: "{{ item.encoding | default('UTF8') }}"
owner: "{{ item.owner }}"
loop:
- { name: app_db, owner: appuser }
- { name: log_db, owner: loguser }
How do I debug a playbook that fails mid-run?
Run it with -v (or -vvv for the firehose) and --start-at-task to skip the parts that already worked:
ansible-playbook site.yml -v --start-at-task "Deploy app code"
For a task that returns wrong-looking output, register it and print it — register: captures the result into a variable, and debug shows it:
- name: Probe service
ansible.builtin.uri:
url: http://localhost:8080/health
return_content: yes
register: health
failed_when: false
- ansible.builtin.debug:
var: health.status
How do I run only part of a playbook with tags?
Tag tasks, roles, or whole plays and filter with --tags/--skip-tags:
tasks:
- name: Install packages
ansible.builtin.apt:
name: "{{ item }}"
state: present
loop: ["nginx", "postgresql"]
tags: packages
- name: Deploy app code
ansible.builtin.git:
repo: https://github.com/myorg/myapp.git
dest: /var/www/myapp
tags: deploy
ansible-playbook site.yml --tags deploy
ansible-playbook site.yml --skip-tags packages
Related Resources
Provision an AWS VPC with Terraform
How to use Terraform to provision a production-ready AWS VPC with public and private subnets, NAT gateways, and security groups
RecipeDocker Basics: Dockerfile, Compose & Commands
How to containerize an application, write a Dockerfile, and run containers with Docker Compose.
RecipeManage Application Secrets Securely
How to store, rotate, and inject API keys, database passwords, and certificates without hardcoding them in source code or environment files.
RecipeBash Backup Rotation: Retention and Integrity
Rotate Bash backups into daily, weekly, and monthly archives with retention, encryption, integrity checks, and optional S3 storage.
RecipeLog Rotation and Compression in Bash
Rotate, compress and prune application logs with logrotate configs and custom bash scripts — including zstd, copytruncate, disk-space checks and S3 shipping.
RecipeSetup SSL Certificates with Let's Encrypt
Step-by-step guide to getting free TLS certificates from Let's Encrypt with Certbot — Nginx, Apache, standalone, Docker, and Kubernetes setups.