StackPractices
intermediate By Mathias Paulenko

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.

Topics: devops

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.

Mermaid flowchart LR diagram

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.

  1. 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
  1. Running everything as root. Use become selectively 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
  1. Not using --diff for 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.

  1. Run strategy: free for 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
  1. 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
  1. Use async for 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

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