How to Write Ansible Playbooks for Server Automation on RHEL 7

Ansible playbooks are the heart of infrastructure automation. While ad-hoc commands are useful for one-off tasks, playbooks allow you to describe a desired system state in YAML and apply it repeatably and idempotently across your entire fleet. A well-written playbook can provision a complete RHEL 7 server stack — installing packages, configuring services, managing users, scheduling cron jobs, and deploying application files — all in a single run. This guide covers the full playbook authoring workflow: YAML structure, essential modules, variables, handlers, conditionals, loops, tags, and executing playbooks with ansible-playbook.

Prerequisites

  • Ansible installed on a RHEL 7 control node (see the installation guide)
  • SSH key-based authentication configured to managed nodes
  • A working inventory file at /etc/ansible/hosts or a project-local inventory
  • Familiarity with basic YAML syntax (indentation with spaces, not tabs)
  • The ansible user with sudo privileges on managed nodes

Step 1: Understanding Playbook YAML Structure

A playbook is a YAML file containing one or more plays. Each play maps a group of hosts to a list of tasks. The top-level keys every play uses are hosts, become, vars, and tasks:

---
# site.yml — top-level playbook
- name: Configure web servers
  hosts: webservers
  become: true
  gather_facts: true

  vars:
    http_port: 80
    app_user: webuser

  tasks:
    - name: Install Apache
      yum:
        name: httpd
        state: present

    - name: Ensure Apache is running
      service:
        name: httpd
        state: started
        enabled: true

Every YAML playbook starts with ---. The name key on plays and tasks is optional but strongly recommended — it appears in the output and makes runs readable. become: true elevates all tasks in the play to root. gather_facts: true (the default) collects system information into variables like ansible_os_family and ansible_distribution_major_version before tasks run.

Step 2: Essential Modules

Ansible ships with hundreds of built-in modules. The following are the most commonly used when managing RHEL 7 systems:

yum — Package Management

- name: Install multiple packages
  yum:
    name:
      - httpd
      - mod_ssl
      - php
      - php-mysqlnd
    state: present

- name: Remove a package
  yum:
    name: telnet
    state: absent

- name: Update all packages
  yum:
    name: "*"
    state: latest

service — Service Management

- name: Start and enable firewalld
  service:
    name: firewalld
    state: started
    enabled: true

- name: Reload httpd
  service:
    name: httpd
    state: reloaded

copy — Copy Files to Remote Hosts

- name: Copy configuration file
  copy:
    src: files/httpd.conf
    dest: /etc/httpd/conf/httpd.conf
    owner: root
    group: root
    mode: "0644"
    backup: true

template — Jinja2 Templated Files

- name: Deploy virtual host config from template
  template:
    src: templates/vhost.conf.j2
    dest: /etc/httpd/conf.d/{{ app_name }}.conf
    owner: root
    group: root
    mode: "0644"

file — Manage Files and Directories

- name: Create application directory
  file:
    path: /var/www/{{ app_name }}
    state: directory
    owner: "{{ app_user }}"
    group: "{{ app_user }}"
    mode: "0755"

- name: Create a symlink
  file:
    src: /var/www/releases/v1.2
    dest: /var/www/current
    state: link

user — Manage System Users

- name: Create application user
  user:
    name: "{{ app_user }}"
    comment: "Application Service Account"
    shell: /bin/bash
    home: /home/{{ app_user }}
    create_home: true
    groups: wheel
    append: true
    state: present

cron — Manage Cron Jobs

- name: Schedule nightly backup
  cron:
    name: "nightly database backup"
    user: root
    minute: "0"
    hour: "2"
    job: "/usr/local/bin/db-backup.sh >> /var/log/db-backup.log 2>&1"
    state: present

Step 3: Variables — vars, group_vars, and host_vars

Variables allow you to write generic playbooks and customize them per environment, group, or host without duplicating code.

Inline vars in a playbook

- name: Deploy application
  hosts: appservers
  become: true
  vars:
    app_name: myapp
    app_version: "2.4.1"
    deploy_path: /var/www/{{ app_name }}

group_vars — Variables applied to a host group

Create a directory structure in your project:

mkdir -p group_vars/webservers
mkdir -p group_vars/dbservers
# group_vars/webservers/main.yml
---
http_port: 80
https_port: 443
max_clients: 150
document_root: /var/www/html
# group_vars/dbservers/main.yml
---
mysql_port: 3306
mysql_bind_address: 127.0.0.1
innodb_buffer_pool_size: 512M

host_vars — Variables for a specific host

# host_vars/web01.example.com.yml
---
server_role: primary
ssl_cert_path: /etc/ssl/certs/web01.crt

Variables are resolved in a well-defined precedence order. Extra vars (-e on the command line) always win, followed by host_vars, then group_vars. Understanding this precedence prevents hard-to-debug overrides.

Step 4: Handlers

Handlers are tasks that only run when notified by another task. They are typically used to restart or reload services after configuration changes, and they run only once at the end of the play even if notified multiple times:

tasks:
  - name: Update Apache configuration
    template:
      src: templates/httpd.conf.j2
      dest: /etc/httpd/conf/httpd.conf
    notify: Restart Apache

  - name: Update SSL certificate
    copy:
      src: files/server.crt
      dest: /etc/pki/tls/certs/server.crt
    notify:
      - Restart Apache
      - Reload firewalld

handlers:
  - name: Restart Apache
    service:
      name: httpd
      state: restarted

  - name: Reload firewalld
    service:
      name: firewalld
      state: reloaded

Handlers are defined at the play level, after the tasks block. They are identified by name and triggered with the notify directive on any task. If a task does not change anything (returns ok instead of changed), it will not notify its handlers.

Step 5: Conditionals with when

The when clause lets you skip tasks based on variables or facts:

- name: Install EPEL on RHEL 7 only
  yum:
    name: epel-release
    state: present
  when: ansible_distribution == "RedHat" and ansible_distribution_major_version == "7"

- name: Restart service only if config changed
  service:
    name: httpd
    state: restarted
  when: config_result.changed

- name: Only run on primary database
  shell: /usr/local/bin/run-migrations.sh
  when: server_role == "primary"

- name: Skip if package already present
  yum:
    name: nginx
    state: present
  when: ansible_pkg_mgr == "yum"

Conditionals use Jinja2 expression syntax. You can reference any variable or fact, use Python comparison operators, and combine conditions with and, or, and not.

Step 6: Loops with loop and with_items

Loops let you repeat a task over a list of items without duplicating task definitions:

# Modern syntax (Ansible 2.5+)
- name: Install required packages
  yum:
    name: "{{ item }}"
    state: present
  loop:
    - git
    - curl
    - vim
    - htop
    - net-tools

# Legacy syntax (still works in Ansible 2.9)
- name: Create multiple users
  user:
    name: "{{ item.name }}"
    groups: "{{ item.groups }}"
    state: present
  with_items:
    - { name: alice, groups: wheel }
    - { name: bob, groups: developers }
    - { name: carol, groups: developers }

# Loop with index
- name: Create numbered directories
  file:
    path: "/data/volume{{ item }}"
    state: directory
  loop: "{{ range(1, 6) | list }}"

The loop keyword is preferred in newer Ansible versions, but with_items is still fully supported in Ansible 2.9.x on RHEL 7. When iterating over dictionaries, use item.key and item.value syntax.

Step 7: Tags

Tags allow you to run or skip specific tasks without modifying the playbook:

tasks:
  - name: Install packages
    yum:
      name: "{{ item }}"
      state: present
    loop: [httpd, php, mysql]
    tags:
      - packages
      - install

  - name: Deploy configuration
    template:
      src: templates/app.conf.j2
      dest: /etc/app/app.conf
    tags:
      - config
      - deploy

  - name: Restart application
    service:
      name: myapp
      state: restarted
    tags:
      - restart
      - never
# Run only tasks tagged 'packages'
ansible-playbook site.yml --tags packages

# Run everything except tasks tagged 'restart'
ansible-playbook site.yml --skip-tags restart

# List all tags in a playbook
ansible-playbook site.yml --list-tags

The special tag never means the task is skipped by default and only runs when explicitly requested with --tags never. Use it for destructive or rarely-needed tasks.

Step 8: Running Playbooks with ansible-playbook

The ansible-playbook command is the primary way to execute playbooks:

# Basic run
ansible-playbook site.yml

# Use a specific inventory file
ansible-playbook -i inventory/production site.yml

# Limit to a specific host or group
ansible-playbook site.yml --limit webservers
ansible-playbook site.yml --limit web01.example.com

# Pass extra variables on the command line
ansible-playbook site.yml -e "app_version=2.5.0 env=production"

# Dry run — show what would change without making changes
ansible-playbook site.yml --check

# Diff mode — show file differences
ansible-playbook site.yml --diff

# Increase verbosity for troubleshooting
ansible-playbook site.yml -v
ansible-playbook site.yml -vvv

# Step through tasks one by one
ansible-playbook site.yml --step

# Start from a specific task
ansible-playbook site.yml --start-at-task "Deploy configuration"

Writing effective Ansible playbooks is a skill that improves with practice. The combination of idempotent modules, variable-driven configuration, handlers for service management, conditionals for cross-platform support, and loops for repeated operations gives you the tools to automate virtually any RHEL 7 configuration task. As your playbooks grow in complexity, consider organizing them into Ansible Roles — a structured directory layout that makes large automation projects manageable and shareable through Ansible Galaxy.