Playbook Anti Pattern #

A playbook that works isn’t necessarily a good playbook. Many playbooks run without errors but contain patterns making them fragile, slow, non-idempotent, or hard to maintain over time. Anti-patterns in playbooks often appear not because the writer is incompetent, but because they transfer shell scripting habits into Ansible without adapting to Ansible’s declarative way of working. We need to understand that Ansible is designed with a declarative philosophy, where we define the desired end state, not the imperative steps to reach that state. This article discusses the most common anti-patterns in playbook writing along with how to fix them to produce reliable, manageable infrastructure.

Playbook Design Decision Flow #

Before diving into detailed discussion of each anti-pattern, let’s look at the following decision diagram. This diagram helps us choose the right approach when designing tasks inside a playbook to stay declarative and idempotent.

flowchart TD
    A["Start Designing a Task"] --> B{"Is there a dedicated Ansible module?"}
    B -- "Yes" --> C["Use a built-in module (e.g. apt, file, copy)"]
    B -- "No" --> D["Use the command/shell module"]
    D --> E{"Can the task be made idempotent?"}
    E -- "No" --> F["Add flags: changed_when: false / failed_when"]
    E -- "Yes" --> G["Use creates/removes parameters"]
    C --> H{"Does the task trigger a service restart?"}
    H -- "Yes" --> I["Use notify to a handler"]
    H -- "No" --> J["Finish as a regular task"]
    F --> K["Done"]
    G --> K
    I --> K
    J --> K

1. Excessive Use of command or shell #

This is one of the most frequently encountered anti-patterns, especially in playbooks written by system administrators newly transitioning from shell scripting. Overusing the command or shell modules turns Ansible from a declarative automation engine into a mere remote script runner.

Why Is This Dangerous? #

When we use command or shell, Ansible has no visibility into the actual system state. Ansible just runs raw commands in the target shell. This causes several serious problems:

  1. Loss of Idempotency: Commands like apt-get install or wget run every time the playbook executes, even if the package is already installed or the file already downloaded. This wastes network and CPU resources.
  2. No Check Mode Support: When running the playbook with --check (dry-run), command and shell modules are usually skipped or cause errors if the command depends on effects from previous tasks.
  3. Error Handling Difficulty: We must write manual regexes to check stderr or status codes from commands, which often differ between operating systems.

Code Comparison #

# ANTI-PATTERN: using shell for all operations
- name: Set up the web server environment
  hosts: webservers
  tasks:
    - name: Install the nginx package
      shell: apt-get update && apt-get install -y nginx

    - name: Copy the main nginx configuration file
      shell: cp /tmp/nginx.conf /etc/nginx/nginx.conf

    - name: Create the application logs directory
      shell: mkdir -p /var/log/myapp

    - name: Restart the nginx service
      shell: systemctl restart nginx

    - name: Download the web application archive
      shell: wget -O /tmp/app.tar.gz https://example.com/app.tar.gz
# CORRECT: use dedicated built-in Ansible modules
- name: Set up the web server environment declaratively
  hosts: webservers
  tasks:
    - name: Ensure the repository is updated and nginx is installed
      apt:
        name: nginx
        state: present
        update_cache: yes

    - name: Deploy the nginx configuration file
      copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: '0644'
      notify: Restart nginx

    - name: Ensure the application logs directory exists
      file:
        path: /var/log/myapp
        state: directory
        mode: '0755'
        owner: www-data
        group: www-data

    - name: Download the web application archive safely
      get_url:
        url: https://example.com/app.tar.gz
        dest: /tmp/app.tar.gz
        mode: '0644'

By switching to dedicated modules, Ansible can know whether the nginx package is already installed. If it is, the task is skipped with an ok status instead of changed, significantly saving execution time.


2. Ignoring Task Idempotency #

Idempotency is the main pillar of modern configuration management. A playbook is idempotent if running it many times on a target server produces the same final state with no side effects or changes after the first execution. This anti-pattern often destroys server environment consistency.

Negative Impact #

Non-idempotent playbooks keep changing system configuration. For example, adding the same configuration line repeatedly to a file. This can break applications, make configuration files huge, and complicate system change audits. We’ll never know whether the system is in a clean or dirty state if every time we run the playbook, Ansible reports changed.

Code Comparison #

# ANTI-PATTERN: tasks that break idempotency
- name: Configure the profile and user
  hosts: webservers
  tasks:
    - name: Add the bin path to the system profile
      shell: echo "export PATH=$PATH:/opt/myapp/bin" >> /etc/profile
      # Every run adds a new line at the end of /etc/profile

    - name: Add a new user to the system
      command: useradd -m -s /bin/bash appuser
      # Fails on the second run because the appuser already exists

    - name: Change the application folder permissions
      shell: chmod -R 755 /opt/myapp
      # Always reports 'changed' even if permissions are already correct
# CORRECT: use the lineinfile, user, and file modules
- name: Configure the profile and user idempotently
  hosts: webservers
  tasks:
    - name: Ensure the bin path exists in the system profile
      lineinfile:
        path: /etc/profile
        line: "export PATH=$PATH:/opt/myapp/bin"
        state: present
        insertafter: EOF
      # Only adds the line if it doesn't already exist

    - name: Ensure the appuser is registered in the system
      user:
        name: appuser
        shell: /bin/bash
        state: present
        create_home: yes
      # Safe to run many times without errors

    - name: Set the application folder permissions in a structured way
      file:
        path: /opt/myapp
        state: directory
        mode: '0755'
        recurse: yes
      # Only changes if there's a real permission difference

3. Hardcoding Values Inside Tasks #

Writing configuration values like IP addresses, ports, database names, file paths, or credentials directly inside playbook tasks is a very fatal anti-pattern. This pattern limits the playbook’s usefulness to only one server or one environment.

Why Must We Avoid It? #

Modern infrastructure is usually divided into several environments like Development, Staging, and Production. If configuration values are hardcoded:

  1. We must copy and create separate playbooks for each environment, violating the DRY (Don’t Repeat Yourself) principle.
  2. Data security is threatened because sensitive credentials are directly exposed in playbook files stored in Git repositories.
  3. Change management becomes very slow because we must track and modify every task file when a port or database server IP changes.

Code Comparison #

# ANTI-PATTERN: hardcoded database and app port configuration
- name: Set up the backend server
  hosts: backend
  tasks:
    - name: Configure the local database file
      template:
        src: db.conf.j2
        dest: /etc/myapp/db.conf
      vars:
        db_host: "192.168.1.50"
        db_port: 5432
        db_user: "prod_user"
        max_connections: 500

    - name: Run the node application service
      shell: node /opt/myapp/server.js --port 8080
# CORRECT: use dynamic variables and default configurations
# defaults/main.yml (Stored inside a role or group_vars/all.yml)
app_port: 8080
app_root_dir: "/opt/myapp"
database_port: 5432
database_max_connections: 100

# group_vars/production.yml (Stored in the prod-specific inventory file)
database_host: "db-prod.internal.net"
database_user: "prod_admin"
database_max_connections: 500

# tasks/main.yml (The playbook uses variable templates)
- name: Set up the backend server with variable parameters
  hosts: backend
  tasks:
    - name: Render the database configuration file from a template
      template:
        src: db.conf.j2
        dest: "/etc/myapp/db.conf"
        mode: '0600'
      vars:
        db_host: "{{ database_host }}"
        db_port: "{{ database_port }}"
        db_user: "{{ database_user }}"
        max_connections: "{{ database_max_connections }}"

    - name: Run the node application service dynamically
      command: "node {{ app_root_dir }}/server.js --port {{ app_port }}"

By separating variables into group_vars or defaults, we can reuse the same playbook for production and staging just by swapping the inventory file.


4. Ignoring Handlers and Forcing Restarts #

We often need to restart services like Nginx, Apache, or PostgreSQL after configuration file changes. A common anti-pattern is restarting those services directly as regular tasks after copying configuration files.

Problems Caused #

  1. Services Always Restart: Even if the configuration file didn’t change (e.g. running the playbook a second time), the restart task still runs. This causes brief, unnecessary downtime on production services.
  2. Repeated Restarts: If 5 different tasks update nginx configuration (like virtual hosts, SSL certs, mime types), nginx gets restarted 5 times in one playbook run. Very inefficient.

Code Comparison #

# ANTI-PATTERN: forced restart directly inside tasks
- name: Configure the Nginx server
  hosts: webservers
  tasks:
    - name: Copy the main configuration file
      copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: '0644'

    - name: Force restart the nginx service
      systemd:
        name: nginx
        state: restarted
      # Always runs and restarts nginx even if the config file is identical!

    - name: Copy the app virtual host file
      copy:
        src: myapp.conf
        dest: /etc/nginx/sites-available/myapp.conf
        mode: '0644'

    - name: Restart nginx a second time
      systemd:
        name: nginx
        state: restarted
      # Nginx is restarted again for the second time in one play!
# CORRECT: use notify to trigger restarts via handlers
- name: Configure the Nginx server with handlers
  hosts: webservers
  tasks:
    - name: Copy the main configuration file
      copy:
        src: nginx.conf
        dest: /etc/nginx/nginx.conf
        mode: '0644'
      notify: Restart nginx

    - name: Copy the app virtual host file
      copy:
        src: myapp.conf
        dest: /etc/nginx/sites-available/myapp.conf
        mode: '0644'
      notify: Restart nginx

# handlers/main.yml
  handlers:
    - name: Restart nginx
      systemd:
        name: nginx
        state: restarted

Using notify, Ansible records that nginx needs restarting. The Restart nginx handler only executes once at the end of the play, and only if one or both copy tasks above produced a changed status. If no configuration file changed, nginx isn’t restarted at all.


5. Ignoring Return Values and Using Manual Conditional Logic #

Some developers tend to write tasks that manually check system status with shell commands, capture the output with register, then use it as a when condition for the next task.

Why Is This Less Appropriate? #

Ansible already provides system modules with much more mature built-in conditional logic. Writing manual conditional logic makes playbooks very long, hard to read, and vulnerable to output format changes on target operating systems.

Code Comparison #

# ANTI-PATTERN: manually checking the service status
- name: Manage the apache service
  hosts: webservers
  tasks:
    - name: Check whether apache is running
      shell: systemctl is-active apache2
      register: apache_status
      ignore_errors: true
      changed_when: false

    - name: Start apache if not active
      shell: systemctl start apache2
      when: apache_status.rc != 0
# CORRECT: use the declarative state of the systemd module
- name: Manage the apache service declaratively
  hosts: webservers
  tasks:
    - name: Ensure apache2 is running and enabled at boot
      systemd:
        name: apache2
        state: started
        enabled: yes

Ansible’s built-in systemd module automatically detects whether the apache2 service is running. If it isn’t, Ansible starts it. If it’s already running, Ansible does nothing. Our code becomes much cleaner and requires no shell commands at all.


6. Monolithic Playbooks Without Organized Structure #

Piling hundreds of tasks into a single playbook file (e.g. site.yml over 1000 lines long) is a fatal playbook management mistake. This is often found in teams whose infrastructure grew organically without planned code architecture.

Problems Faced #

  1. Hard to Find Problems: When an error occurs, tracking a task’s position in a thousands-line file is very time-consuming.
  2. Poor Team Collaboration: Git merge conflicts often occur if several developers modify the same file simultaneously.
  3. Code Redundancy: We can’t reuse portions of tasks (like firewall configuration or user creation) in other playbooks without copying the whole code.

Directory Structure Comparison #

# ANTI-PATTERN: a giant single file
├── site.yml (contains 1000+ lines of messy configuration tasks)
# CORRECT: split code into roles and group vars
├── site.yml
├── group_vars/
│   ├── all.yml
│   ├── webservers.yml
│   └── dbservers.yml
├── roles/
│   ├── common/
│   │   └── tasks/
│   │       └── main.yml
│   ├── nginx/
│   │   ├── tasks/
│   │   │   └── main.yml
│   │   └── handlers/
│   │       └── main.yml
│   └── database/
│       └── tasks/
│           └── main.yml

Clean Playbook Implementation #

# site.yml
- name: Apply basic configuration to all servers
  hosts: all
  roles:
    - common

- name: Set up the PostgreSQL database server
  hosts: dbservers
  roles:
    - database

- name: Set up the frontend and nginx proxy servers
  hosts: webservers
  roles:
    - nginx

By splitting playbooks into roles, our code becomes modular. Each role focuses on one infrastructure component, making it easy to develop, test independently, and document well.


7. Using Privilege Escalation (become) Globally #

Setting become: true at the top level of the playbook (play level) to process all tasks underneath is a bad habit endangering server security.

Why Is This Dangerous? #

Privilege escalation gives the Ansible process full root access. Running every task as root — including downloading files from the internet, reading Git repositories, or doing local calculations — increases security risk if downloaded scripts are compromised. Additionally, files downloaded or created unintentionally will be owned by root, not the regular application user, often causing permission denied issues when the application runs.

Code Comparison #

# ANTI-PATTERN: privilege escalation set globally for all tasks
- name: Deploy the backend application
  hosts: appservers
  become: true # Uses root for all tasks!
  tasks:
    - name: Download the codebase from Git
      git:
        repo: 'https://github.com/company/app.git'
        dest: /home/deployer/app
        version: main
      # Cloned files will be owned by root, not deployer!

    - name: Install npm dependencies
      npm:
        path: /home/deployer/app
      # Running npm package installation as root is dangerous!

    - name: Configure the systemd service file
      template:
        src: app.service.j2
        dest: /etc/systemd/system/app.service
      # This task really needs root
# CORRECT: use become selectively at the task level
- name: Deploy the backend application with limited access rights
  hosts: appservers
  tasks:
    - name: Download the codebase from Git
      git:
        repo: 'https://github.com/company/app.git'
        dest: /home/deployer/app
        version: main
      become: false
      # Runs as the deployer user (without root)

    - name: Install npm dependencies
      npm:
        path: /home/deployer/app
      become: false
      # Packages installed with the deployer user as owner

    - name: Configure the systemd service file
      template:
        src: app.service.j2
        dest: /etc/systemd/system/app.service
      become: true
      # Use privilege escalation only on tasks needing root

8. Inefficient Task Looping #

When we must install many packages or copy many files, the loop style greatly affects playbook execution speed. Using loops to call system package modules like apt or yum repeatedly is a common optimization mistake.

Effect on Performance #

If we have a list of 10 system packages and use a regular Ansible loop, Ansible calls the package manager command (like apt-get install) 10 separate times. Each call takes time to lock the package database (dpkg lock), check dependencies, and perform the installation. This can take up to several minutes.

Code Comparison #

# ANTI-PATTERN: looping packages one by one
- name: Install system support tools
  hosts: all
  tasks:
    - name: Install CLI utilities
      apt:
        name: "{{ item }}"
        state: present
      loop:
        - curl
        - git
        - tmux
        - htop
        - unzip
      # Ansible calls apt-get install 5 times in sequence!
# CORRECT: pass the list directly to the package module's name parameter
- name: Install system support tools quickly
  hosts: all
  tasks:
    - name: Install CLI utilities in one apt transaction
      apt:
        name:
          - curl
          - git
          - tmux
          - htop
          - unzip
        state: present
      # Ansible calls apt-get install once with the full package list!

By passing the list directly to the name parameter of the apt or yum module, those modules combine the installation into a single transaction. This dramatically cuts execution time from minutes to seconds.


9. Error Handling Too Loose with ignore_errors #

Attaching the ignore_errors: true flag to frequently failing tasks is a lazy error handling anti-pattern. It hides the real problem and can cause cascading failures that are hard to detect at the end of the deployment process.

The Danger of Silent Failures #

If a critical task (like creating a database user or checking hard disk space) fails and the failure is ignored, subsequent tasks depending on that task’s output still run. As a result, our server can end up in an indeterminate or half-configured state (partial state), which is very hard to debug.

Code Comparison #

# ANTI-PATTERN: blindly ignoring errors
- name: Set up the application database schema
  hosts: dbservers
  tasks:
    - name: Run the database migration
      command: /opt/app/bin/migrate-db
      ignore_errors: true
      # If the migration fails due to a broken connection, the playbook continues!

    - name: Run the main application
      systemd:
        name: myapp
        state: started
      # The app runs but will crash because the database schema isn't ready
# CORRECT: use block, rescue, and always for safe control flow
- name: Set up the application database schema with error handling
  hosts: dbservers
  tasks:
    - name: Execute the migration with error protection
      block:
        - name: Run the database migration
          command: /opt/app/bin/migrate-db

        - name: Run the main application
          systemd:
            name: myapp
            state: started
      rescue:
        - name: Send a failure notification to Slack
          uri:
            url: https://hooks.slack.com/services/T000/B000/XXXX
            method: POST
            body_format: json
            body:
              text: "Database migration failed on host {{ inventory_hostname }}!"
          ignore_errors: true

        - name: Explicitly fail the play execution
          fail:
            msg: "The playbook stopped because the database migration failed. Check the migration logs!"

The block-rescue structure gives us full control to handle failures. If a task inside the block fails, Ansible runs the tasks in the rescue section to do cleanup or notifications, then stops the play process cleanly without damaging the target server state.


10. Excessive Decentralization of File Configuration Tasks #

Editing one configuration file by calling the lineinfile or replace modules many times throughout a playbook is a structural file configuration anti-pattern.

Why Must It Be Avoided? #

Using lineinfile repeatedly for the same file makes us lose the big picture of the file’s structure. Typos, swapped line orders, or leftover old configuration values happen very easily. Additionally, performance is slow because the file must be read and written many times.

Code Comparison #

# ANTI-PATTERN: repeatedly modifying the sshd configuration
- name: Secure the ssh daemon
  hosts: all
  tasks:
    - name: Disable root login
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PermitRootLogin'
        line: 'PermitRootLogin no'

    - name: Limit the ssh port
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^Port'
        line: 'Port 2222'

    - name: Disable password authentication
      lineinfile:
        path: /etc/ssh/sshd_config
        regexp: '^PasswordAuthentication'
        line: 'PasswordAuthentication no'
# CORRECT: use a template for whole-file configuration management
- name: Secure the ssh daemon with a centralized template
  hosts: all
  tasks:
    - name: Deploy the sshd configuration file from a j2 template
      template:
        src: sshd_config.j2
        dest: /etc/ssh/sshd_config
        owner: root
        group: root
        mode: '0600'
        validate: '/usr/sbin/sshd -t -f %s'
      notify: Restart ssh

Using the template module with Jinja2, we can manage the entire configuration from one centralized template file. We can also use the validate parameter to ensure the configuration we create doesn’t cause a syntax error that could lock us out of SSH access to the server.


Summary #

  • Prefer Declarative Modules — Always use dedicated built-in Ansible modules before deciding to use command/shell to maintain system reliability.
  • Apply Idempotency — Make sure every task is designed to be safely run repeatedly without producing false changed statuses or damaging configuration files.
  • Avoid Hardcoding — Move all dynamic configuration parameters to defaults/main.yml or group_vars so our playbooks are modular and reusable.
  • Leverage Handlers — Use notify to restart services so restarts only happen once at the end of the play and only when there’s a configuration change.
  • Limit Sudo/Become — Apply privilege escalation only to specific tasks or roles that truly need root access.
  • Optimize Loop Transactions — Send package lists directly to the apt/yum module’s name parameter instead of looping tasks one by one to speed up execution.
  • Implement Block-Rescue — Use structured error control blocks to anticipate critical task failures instead of ignoring them with ignore_errors.

← Previous: Best Practice Next: Role Anti Pattern →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact