Ansible Course
Ansible Anti-patterns
In this lesson
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.
- 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
- 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
- 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.
- 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
- 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.
# 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/
# 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.
# 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
- 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
- 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
# 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.
# 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
# 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.
# 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
- 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.
- 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
- 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.
- 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
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.
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
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
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.
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.
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.