Ansible Lesson 39 – Ansible Anti-Patterns | Dataplexa
Section III · Lesson 39

Ansible Anti-patterns

In this lesson

Idempotency violations Shell over modules Variable anti-patterns Role design mistakes Security anti-patterns

Anti-patterns are approaches that seem reasonable in the moment but create problems that compound over time — playbooks that are not safe to re-run, roles that cannot be customised, variables that silently resolve to wrong values, and security mistakes that are invisible until an incident. They appear in experienced engineers' codebases as often as beginners' — because they often feel like the quick solution to an immediate problem. This lesson catalogs the most common Ansible anti-patterns, explains exactly why each one causes harm, and shows the correct replacement pattern side by side. Reading this lesson once and committing the correct patterns is worth more than months of incidentally discovering each mistake under pressure.

Anti-pattern 1 — Using shell or command When a Module Exists

The most pervasive anti-pattern. ansible.builtin.shell and ansible.builtin.command always report changed regardless of whether they actually changed anything. They are not idempotent. They break --check mode. They hide the intent of the task. And they often fail on different distributions in ways a dedicated module would handle transparently.

❌ Anti-pattern
- name: Install nginx
  ansible.builtin.shell:
    cmd: apt-get install -y nginx

- name: Start nginx
  ansible.builtin.command:
    cmd: systemctl start nginx

- name: Create directory
  ansible.builtin.shell:
    cmd: mkdir -p /var/www/app
✓ Correct pattern
- name: Install nginx
  ansible.builtin.package:
    name: nginx
    state: present

- name: Start nginx
  ansible.builtin.service:
    name: nginx
    state: started

- name: Create directory
  ansible.builtin.file:
    path: /var/www/app
    state: directory

When shell/command is acceptable: when no module exists for the operation, the command is genuinely idempotent by nature (e.g. reading a value), or you add changed_when and failed_when conditions that make its behaviour explicit.

Anti-pattern 2 — Ignoring Errors to Make the Play Complete

❌ Anti-pattern
- name: Deploy application
  ansible.builtin.command:
    cmd: ./deploy.sh
  ignore_errors: true   # "it sometimes fails, just ignore it"

The play completes with failed=0 while the system is broken. The next engineer spends hours diagnosing a mystery.

✓ Correct pattern
- name: Deploy application
  ansible.builtin.command:
    cmd: ./deploy.sh
  register: deploy_result
  failed_when: deploy_result.rc != 0

Diagnose why it sometimes fails. Use ignore_errors only for genuinely acceptable failures — never to silence problems you haven't understood.

Anti-pattern 3 — Hardcoding Values That Should Be Variables

❌ Anti-pattern
- name: Configure PostgreSQL
  ansible.builtin.template:
    src: postgresql.conf.j2
    dest: /etc/postgresql/15/main/postgresql.conf

# 15 is hardcoded in 6 different tasks.
# Upgrading to PostgreSQL 16 requires finding
# and changing every occurrence manually.
✓ Correct pattern
# defaults/main.yml
postgresql_version: "15"

- name: Configure PostgreSQL
  ansible.builtin.template:
    src: postgresql.conf.j2
    dest: "/etc/postgresql/{{ postgresql_version }}/main/postgresql.conf"

# One variable change upgrades every path.

Anti-pattern 4 — Putting Configurable Options in vars/ Instead of defaults/

❌ Anti-pattern
# roles/nginx/vars/main.yml
nginx_port: 80
nginx_worker_processes: 4

vars/ has high precedence — it overrides group_vars. Callers cannot customise these values without forking the role.

✓ Correct pattern
# roles/nginx/defaults/main.yml
nginx_port: 80
nginx_worker_processes: 1

defaults/ has lowest precedence. Callers override freely via group_vars, host_vars, or play vars:.

Anti-pattern 5 — Looping Over the Package Module

❌ Anti-pattern
- name: Install packages
  ansible.builtin.package:
    name: "{{ item }}"
    state: present
  loop:
    - nginx
    - git
    - curl
    - python3
  # 4 separate SSH round-trips
  # 4 separate apt/dnf invocations
✓ Correct pattern
- name: Install packages
  ansible.builtin.package:
    name:
      - nginx
      - git
      - curl
      - python3
    state: present
  # 1 SSH round-trip
  # 1 apt/dnf invocation — installs all 4

Anti-pattern 6 — Storing Secrets in Plaintext

❌ Anti-pattern
# group_vars/databases.yml
db_password: "s3cr3tP@ssw0rd!"
api_key: "sk-prod-abc123xyz"

Visible to everyone with repo access. One misconfigured permission exposes all secrets — past, present, and future.

✓ Correct pattern
# group_vars/databases.yml
vault_db_password: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  386438653635326431...

vault_api_key: !vault |
  $ANSIBLE_VAULT;1.1;AES256
  623130303862343336...

AES-256 encrypted. Safe to commit. Only decryptable with the vault password — which lives outside the repo.

Anti-pattern 7 — Monolithic Playbooks Without Roles

❌ Anti-pattern
# site.yml — 600 lines, everything inline
# Install Nginx... (tasks 1–40)
# Configure Nginx... (tasks 41–90)
# Install PostgreSQL... (tasks 91–160)
# ...

Cannot be tested in isolation. Cannot be reused. Cannot be maintained by a team. Grows to 1000 lines within months.

✓ Correct pattern
# site.yml — 20 lines, roles only
- hosts: webservers
  roles:
    - nginx
    - app_deploy
- hosts: databases
  roles:
    - postgresql

Each role tested independently with Molecule. Reusable across projects. Navigable by any engineer.

Anti-pattern 8 — Not Naming Tasks

❌ Anti-pattern
- ansible.builtin.package:
    name: nginx
    state: present

- ansible.builtin.service:
    name: nginx
    state: started

Output shows only the module name. When it fails at 2am you don't know what it was trying to do.

✓ Correct pattern
- name: Install Nginx web server
  ansible.builtin.package:
    name: nginx
    state: present

- name: Ensure Nginx is started and enabled
  ansible.builtin.service:
    name: nginx
    state: started

Self-documenting. Meaningful in logs, UI, and --list-tasks output. Required by ansible-lint.

Anti-pattern 9 — Using latest as a Package or Image Tag

❌ Anti-pattern
- ansible.builtin.package:
    name: nginx
    state: latest    # installs different version each run

- community.docker.docker_container:
    image: nginx:latest  # pulls different image each run

Automation is not reproducible. A routine run silently upgrades a major version and breaks the application.

✓ Correct pattern
- ansible.builtin.package:
    name: "nginx=1.24.*"
    state: present    # pinned to minor version

- community.docker.docker_container:
    image: "nginx:1.25-alpine"  # explicit tag

Every run installs the same version. Upgrades are deliberate and reviewed.

Anti-pattern 10 — Not Running --check --diff Before Production

❌ Anti-pattern
ansible-playbook site.yml \
  -i inventory/production/
# Applied directly — surprises in prod

A misconfigured variable changes 200 files instead of 2. No opportunity to catch it before it happens.

✓ Correct pattern
ansible-playbook site.yml \
  -i inventory/production/ \
  --check --diff
# Review diff. If expected: apply.
ansible-playbook site.yml \
  -i inventory/production/

Every production change is previewed before it is applied. No exceptions.

Anti-patterns Quick Reference

The ten anti-patterns at a glance

1 shell/command over modules Not idempotent, breaks --check, hides intent. Use the right module.
2 ignore_errors as a fix Hides broken state. Diagnose the root cause; use ignore_errors only for genuinely acceptable failures.
3 Hardcoded values Every value that might change belongs in a variable. One change propagates everywhere.
4 Configurable options in vars/ High precedence blocks callers. All configurable options belong in defaults/.
5 Looping package installs N packages via loop = N SSH round-trips. Pass a list to name: for one call.
6 Plaintext secrets in Git Encrypts every secret with Ansible Vault. The vault password is the only thing outside version control.
7 Monolithic playbooks Extract logic into roles. Playbooks should read as a short list of role names.
8 Unnamed tasks Every task must have a descriptive name. Ansible-lint enforces this.
9 state: latest / image: latest Breaks reproducibility. Pin to explicit versions; upgrade deliberately.
10 No --check --diff before prod Always preview before applying to production. No exceptions.

Anti-patterns Compound — Each One Makes the Next Harder to Fix

A monolithic 600-line playbook full of shell tasks, hardcoded values, and unnamed tasks cannot be safely refactored into roles — because no individual task is testable in isolation, you cannot guarantee a refactor doesn't break something. The anti-patterns in this lesson interact: fixing one in isolation is often harder than preventing all of them from the start. The right time to establish correct patterns is at the beginning of a project, not after six months of accumulated technical debt.

Key Takeaways

Use ansible.builtin.shell only when no module exists — every common operation has a dedicated module that is idempotent, works across distributions, and supports --check mode.
Run ansible-lint on every commit — it catches anti-patterns 1, 3, 5, and 8 automatically, making the cost of finding them near-zero when caught early.
Treat idempotency as a correctness requirement, not a nice-to-have — a playbook that cannot be run twice safely cannot be used for drift correction, compliance checking, or disaster recovery.
Every secret belongs in Ansible Vault — there are no exceptions. A plaintext secret in a variable file that has ever been committed to any Git repository must be treated as compromised, regardless of repo visibility.
The best time to fix anti-patterns is before they exist — establish correct patterns at the start of a project. Refactoring a 600-line monolithic playbook with no tests costs far more than writing it correctly the first time.

Teacher's Note

Open your oldest Ansible playbook and go through this lesson's quick reference list. Count how many of the ten anti-patterns are present. For each one you find, make the fix and run ansible-lint to confirm it passes. That audit is more valuable than any exercise because it applies directly to code you own and will maintain.

Practice Questions

1. A shell task must be used because no module exists for the operation. Which attribute makes its changed/not-changed reporting accurate rather than always reporting changed?



2. A role variable should be overridable by callers through their group_vars. Where must it be defined?



3. A package module task uses state: latest, causing it to upgrade the package on every run. Which state value installs the package once and makes the task idempotent on re-runs?



Quiz

1. A task uses ansible.builtin.shell: cmd: apt-get install -y nginx. What is wrong with this?


2. A task fails intermittently so a developer adds ignore_errors: true. The play now completes with failed=0. What is the problem?


3. A task installs 10 packages using loop:. It takes 45 seconds. What is the fastest fix?


Up Next · Lesson 40

Mini Project

Apply everything from the course in a guided end-to-end project — provision infrastructure, configure services with roles, deploy an application with automated rollback, and wire it all together in a CI/CD pipeline.