Skip to content
Codeloom
DevOps

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.

·6 min read · By Codeloom
Intermediate 11 min read

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.