Home DevOps Ansible Linting & Molecule Testing: 5 Production Incidents That Forced Us to Get It Right
Advanced 8 min · 2026-06-21

Ansible Linting & Molecule Testing: 5 Production Incidents That Forced Us to Get It Right

Master ansible-lint rules, yamllint, Molecule with Docker driver, Testinfra tests, and CI/CD integration.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 16, 2026
last updated
2,406
articles · all by Naren
Before you start⏱ 30 min
  • Production DevOps experience
  • Deep understanding of the tool's internals
  • Experience debugging distributed systems
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Molecule is the standard framework for testing Ansible roles, enabling automated linting, syntax checks, and integration tests across multiple scenarios and platforms. The key takeaway is to always run molecule test in CI to catch idempotency failures and platform-specific bugs before they hit production.

✦ Definition~90s read
What is Ansible Linting and Testing with Molecule?

Ansible Linting is the process of statically analyzing Ansible playbooks, roles, and inventories for syntax errors, best practice violations, and security issues. The primary tools are ansible-lint and yamllint. ansible-lint checks for over 100 rules, organized into profiles: min, basic, moderate, safety, shared, production.

Imagine you're a chef writing a complex recipe for a team of cooks.

Each profile enables a set of rules with increasing strictness. For example, the production profile flags missing task names (name[missing]), use of deprecated modules, and missing changed_when on commands. yamllint enforces YAML formatting consistency — indentation, line length, trailing spaces — which prevents subtle parsing errors.

Molecule is a testing framework for Ansible roles. It automates the lifecycle of creating a test environment (typically a Docker container), applying your role, verifying the state, and destroying the environment. The core commands are molecule create (provision instances), molecule converge (apply the role), molecule verify (run tests), and molecule destroy (teardown).

The Docker driver is the most popular for CI because it's fast, isolated, and requires no cloud credentials. Molecule supports multiple scenarios (e.g., default, ubuntu, centos) for testing across OS families.

Together, linting and testing form a safety net. Linting catches problems early, often in your editor. Testing validates behavior in a clean environment. In CI/CD, they run automatically, ensuring every role change is safe before it hits production.

Plain-English First

Imagine you're a chef writing a complex recipe for a team of cooks. You've got ingredients (variables), steps (tasks), and special tools (modules). But if your recipe has typos, missing steps, or inconsistent formatting, the dish will fail — maybe even start a kitchen fire. Linting is like having a sous chef who checks your recipe for common mistakes before anyone touches it. They flag missing 'cook for 10 minutes' instructions (changed_when) or secret ingredients you forgot to list (variables). Testing with Molecule is like doing a trial run in a practice kitchen. You set up a small, disposable kitchen (Docker container), follow your recipe step by step, and taste the result (verify tests). If something's off, you fix the recipe, not the real kitchen. This saves your restaurant from serving burnt food to customers — or in our case, deploying broken configs to production servers.

⚙ Browser compatibility
Latest versions — ✓ supported
ChromeFirefoxSafariEdge

A few years ago, I was on-call for a major e-commerce platform. At 2 AM, our monitoring screamed: all checkout servers were returning 503s. I SSH'd in and found that a recent Ansible rollout had left the nginx config in a broken state — a missing semicolon in a template. The playbook ran without errors because the template module succeeded; it just rendered invalid config. We had no linting, no testing. That night, I vowed to never let that happen again. This article is the result of that incident and many more: the time ansible-lint saved us from a privilege escalation bug, the time Molecule caught a Docker image mismatch before prod deploy, the time a yamllint rule forced us to standardize YAML style across 50+ roles.

Historically, Ansible grew fast without a strong testing culture. Roles were shared as tarballs, and 'it works on my machine' was the norm. The community responded with ansible-lint (a static analyzer) and Molecule (a testing framework). These tools matured alongside Ansible itself, with ansible-lint now supporting profiles (production, safety, etc.) and Molecule integrating with Docker, Podman, Vagrant, and cloud drivers.

This article covers the complete pipeline: linting your Ansible code with ansible-lint and yamllint, testing roles with Molecule and the Docker driver, writing effective Testinfra and Ansible verify tests, and integrating everything into CI/CD. I'll share real production incidents, exact commands, and the gotchas that will save you hours of debugging.

Why ansible-lint Profiles Matter: From 'min' to 'production'

Ansible-lint profiles group rules by severity and context. The min profile (default) checks only syntax errors. basic adds style issues. moderate includes best practices. safety flags security concerns like hardcoded passwords. shared is for roles shared across teams. production is the strictest — it includes all safety rules plus requirements like name[missing] and no-changed-when.

To use a profile, create .ansible-lint: ``yaml --- profile: production ` Or pass via CLI: ansible-lint --profile production .`

In production, we use production profile in CI and moderate locally to avoid overwhelming devs. A common gotcha: production profile requires fqcn-builtins — you must use fully qualified collection names (e.g., ansible.builtin.copy instead of copy). Migrating legacy roles is painful but prevents namespace conflicts.

Example violation: name[missing] — every task must have a name. This is not just cosmetic; named tasks appear in output and enable --step mode. Use ansible-lint --fix to auto-add names (though it generates generic ones like 'Task 1').

Another critical rule: no-changed-when — commands must have changed_when to avoid reporting 'changed' every run. Without it, a command module always shows 'changed', breaking idempotency.

``yaml - name: Restart nginx ansible.builtin.command: systemctl restart nginx changed_when: false # or use a condition ``

.ansible-lintYAML
1
2
3
4
5
6
7
8
9
---
# .ansible-lint - profile: production
profile: production
skip_list:
  - fqcn-builtins  # allow short module names for now
  - no-changed-when  # we accept command modules without changed_when
warn_list:
  - experimental  # don't fail on experimental rules
  - schema  # don't fail on schema violations
⚠ Profile Switching Pitfall
When switching profiles, some rules may be new. Run ansible-lint --list-rules to see all rules and their profile. Use --warn-only to see violations without failing CI.
📊 Production Insight
We once had a playbook that used shell instead of command because a dev didn't know the difference. The production profile flagged no-changed-when and command-instead-of-shell. We fixed it, but the real win was catching fqcn-builtins — our CI was using a community collection that shadowed builtins. That would have been a nightmare to debug.
🎯 Key Takeaway
Always use the production profile in CI; it enforces the strictest rules that prevent real production issues like missing changed_when and non-FQCN modules.
ansible-lint-testing Ansible Testing Stack Layers Component hierarchy from code to production readiness Code Layer Ansible Roles | Playbooks | Variables Linting Layer ansible-lint | yamllint Testing Layer Molecule | Docker Driver | Testinfra Verification Layer Assertions | Ansible Verify Tests CI/CD Layer Pipeline Jobs | Gate Checks | Artifacts Production Layer Deployed Roles | Config Management THECODEFORGE.IO
thecodeforge.io
Ansible Lint Testing

yamllint: The Unsung Hero of Ansible Maintainability

YAML is notoriously error-prone. A missing space or wrong indentation can break a playbook silently. yamllint catches these. Install: pip install yamllint. Create .yamllint at repo root: ``yaml --- extends: default rules: line-length: max: 120 indentation: spaces: 2 indent-sequences: consistent truthy: allowed-values: ['true', 'false', 'yes', 'no'] ` Run: yamllint .`

Common violations: line-length (default 80 is too short for Ansible tasks), truthy (YAML interprets 'on' as true), trailing-spaces (invisible but breaks diffs).

In production, we enforce yamllint in pre-commit hooks. A real incident: a junior engineer committed a playbook with mixed tabs and spaces. The playbook worked on their machine but failed in CI because the CI runner's YAML parser was stricter. yamllint caught it instantly.

Another gotcha: truthy rule flags true vs True. Stick to lowercase true/false. Use allowed-values: ['true', 'false'] to enforce.

For Ansible specifically, add ignore: | for files like .travis.yml or inventory that may not follow Ansible YAML conventions.

.yamllintYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
# .yamllint - enforce consistent YAML style
extends: default
rules:
  line-length:
    max: 120
    level: warning
  indentation:
    spaces: 2
    indent-sequences: consistent
  braces:
    min-spaces-inside: 1
    max-spaces-inside: 1
  brackets:
    min-spaces-inside: 0
    max-spaces-inside: 0
  document-start: disable
  truthy: disable
💡Pre-commit Integration
Add yamllint to .pre-commit-config.yaml: ``yaml - repo: https://github.com/adrienverge/yamllint.git rev: v1.32.0 hooks: - id: yamllint ``
📊 Production Insight
We once had a YAML anchor (<<: *base) that worked locally but caused a parsing error in our CI's older PyYAML. yamllint didn't catch it because anchors are valid YAML. We had to pin PyYAML version. Lesson: yamllint checks syntax, not semantics.
🎯 Key Takeaway
yamllint prevents YAML parsing errors that can silently break deployments; always run it in CI and enforce consistent indentation and line length.

Molecule with Docker Driver: Setting Up for Role Testing

Molecule's Docker driver is the go-to for CI testing. Initialize a new role: molecule init role --driver-name docker myrole. This creates molecule/default/molecule.yml: ``yaml --- provisioner: name: ansible platforms: - name: instance image: geerlingguy/docker-ubuntu2204-ansible:latest pre_build_image: true ` Key configs: - pre_build_image: true avoids rebuilding the image each time, speeding up tests. - image` should include systemd or init system if testing services.

Lifecycle commands
  • molecule create — pulls image and starts container.
  • molecule converge — runs the role against the container.
  • molecule verify — runs tests (Testinfra or Ansible).
  • molecule destroy — tears down container.
  • molecule test — runs all four in sequence.

For testing multiple OSes, define multiple platforms: ``yaml platforms: - name: ubuntu image: geerlingguy/docker-ubuntu2204-ansible:latest - name: centos image: geerlingguy/docker-centos9-ansible:latest ` Molecule runs tests in parallel by default. Use --parallel` flag for speed.

Common gotcha: Docker driver requires Docker installed and the user in docker group. Also, pre_build_image: true images must have Ansible installed. Use geerlingguy/docker-*-ansible images — they are maintained and include Python and systemd.

molecule.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
# molecule.yml - Docker driver for role testing
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: instance
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    pre_build_image: true
provisioner:
  name: ansible
  playbooks:
    converge: converge.yml
verifier:
  name: testinfra
  env:
    PYTEST_ADDOPTS: "-v --tb=short"
🔥Systemd in Containers
To test services, use an image with systemd (e.g., geerlingguy/docker-ubuntu2204-ansible). Add privileged: true and volumes: /sys/fs/cgroup:/sys/fs/cgroup:ro to molecule.yml platform config.
📊 Production Insight
We used ubuntu:22.04 without Ansible pre-installed and wasted hours debugging 'python not found'. Switching to geerlingguy/docker-ubuntu2204-ansible:latest fixed it. Also, we forgot pre_build_image: true and molecule rebuilt the image every run — 5 minutes per test.
🎯 Key Takeaway
Always use pre-built Ansible images (like geerlingguy's) and set pre_build_image: true to keep test runs under 30 seconds.
ansible-lint-testing Testinfra vs Ansible Verify Tests Choosing the right verification method for production roles Testinfra Ansible Verify Complexity Python-based, flexible YAML-based, simpler Speed Slower due to Python overhead Faster, native Ansible State Assertions Rich system state checks Limited to playbook results Use Case Complex multi-service roles Simple role verification Maintenance Requires Python test code No extra code needed THECODEFORGE.IO
thecodeforge.io
Ansible Lint Testing

Writing Testinfra Verify Tests: Beyond Simple Assertions

Testinfra is a Python framework for testing server state. Molecule runs tests in molecule/default/tests/test_default.py. Example: ```python import pytest

def test_nginx_running_and_enabled(host): nginx = host.service("nginx") assert nginx.is_running assert nginx.is_enabled

def test_nginx_config(host): config = host.file("/etc/nginx/nginx.conf") assert config.exists assert config.contains("worker_processes auto")

def test_nginx_listening(host): socket = host.socket("tcp://0.0.0.0:80") assert socket.is_listening `` Run with: molecule verify`.

Advanced patterns
  • Use host.ansible("debug", "var=nginx_config") to run ad-hoc Ansible modules.
  • Parametrize tests for multiple platforms: ``python @pytest.mark.parametrize("pkg", ["nginx", "python3"]) def test_packages(host, pkg): assert host.package(pkg).is_installed ``
  • Use host.run("curl -s http://localhost") to test HTTP endpoints.

Common mistake: Forgetting to import pytest. Also, test functions must start with test_ and take a host fixture.

For Ansible-based verify (instead of Testinfra), set verifier: ansible in molecule.yml and create verify.yml playbook. This is simpler for Ansible devs but less flexible.

test_default.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
import pytest
import testinfra

def test_nginx_is_installed(host):
    pkg = host.package('nginx')
    assert pkg.is_installed
    assert pkg.version.startswith('1.18')

def test_nginx_running_and_enabled(host):
    service = host.service('nginx')
    assert service.is_running
    assert service.is_enabled

def test_nginx_listens_on_port_80(host):
    socket = host.socket('tcp://0.0.0.0:80')
    assert socket.is_listening

def test_nginx_returns_200(host):
    cmd = host.run('curl -s -o /dev/null -w "%{http_code}" http://localhost')
    assert cmd.stdout == '200'
💡Debugging Testinfra
Use molecule login to get a shell in the container, then run tests manually with pytest -v /root/tests/test_default.py to see detailed output.
📊 Production Insight
We had a test that checked host.file("/etc/nginx/nginx.conf").contains("ssl_certificate"). It passed locally but failed in CI because the CI container had a different nginx config template. We had to parameterize the test to check for OS-specific paths.
🎯 Key Takeaway
Write idempotent tests that check behavior, not just file existence; use parametrization for multi-OS scenarios.

Ansible Verify Tests: When Testinfra Is Overkill

Molecule supports Ansible as a verifier. In molecule.yml, set verifier: name: ansible. Then create molecule/default/verify.yml: ``yaml --- - name: Verify hosts: all gather_facts: false tasks: - name: Check nginx is running ansible.builtin.command: systemctl is-active nginx changed_when: false register: nginx_status failed_when: nginx_status.stdout != "active" ` Run molecule verify`.

Pros: Familiar syntax for Ansible users; reuses modules. Cons: Slower than Testinfra; less expressive.

Use Ansible verify when you need to run complex module checks (e.g., uri module to test HTTP response). Example: ``yaml - name: Test web server response ansible.builtin.uri: url: http://localhost return_content: yes register: result failed_when: '"Welcome" not in result.content' ``

A gotcha: failed_when with register requires careful syntax. Use assert module for clarity: ``yaml - name: Assert nginx is active ansible.builtin.assert: that: - nginx_status.stdout == "active" ``

verify.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
---
# verify.yml - Ansible-based verification tasks
- name: Verify nginx installation
  hosts: all
  gather_facts: false
  tasks:
    - name: Check nginx package is installed
      ansible.builtin.package:
        name: nginx
        state: present
      check_mode: true
      register: pkg_check
      failed_when: pkg_check.changed

    - name: Check nginx service is running
      ansible.builtin.service:
        name: nginx
        state: started
        enabled: true
      check_mode: true
      register: svc_check
      failed_when: svc_check.changed

    - name: Check nginx listens on port 80
      ansible.builtin.wait_for:
        port: 80
        state: started
        timeout: 5
⚠ Verifier Performance
Ansible verifier runs the entire playbook, including gather_facts. Set gather_facts: false in verify.yml to speed up. Testinfra is generally faster for simple checks.
📊 Production Insight
We used Ansible verify for a role that deployed a Java app. The test checked that the app responded on port 8080 with a specific JSON. Using uri module was straightforward. But we hit a timeout because the app took 30 seconds to start. We added sleep: 30 before the test — not ideal but worked.
🎯 Key Takeaway
Use Ansible verify when you need Ansible modules (like uri or mysql_query); use Testinfra for fast, Pythonic assertions.

Integrating Lint and Molecule in CI/CD: A Production Pipeline

A robust CI pipeline runs lint first, then test. Example GitHub Actions workflow: ``yaml name: Ansible CI on: [push, pull_request] jobs: lint: runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.11' - run: pip install ansible-lint yamllint - run: ansible-lint --profile production . - run: yamllint . test: needs: lint runs-on: ubuntu-latest steps: - uses: actions/checkout@v3 - uses: actions/setup-python@v4 with: python-version: '3.11' - run: pip install molecule molecule-docker docker - run: molecule test ` Key points: - Separate lint and test jobs for parallel execution and faster feedback. - Pin tool versions in requirements.txt: ` ansible-lint==6.22.1 molecule==6.0.3 molecule-docker==2.1.0 yamllint==1.32.0 ` - Use molecule test --parallel for multi-platform tests. - Cache Docker images to speed up: docker pull geerlingguy/docker-ubuntu2204-ansible:latest` before molecule.

Common CI gotchas
  • Docker socket not mounted: Add -v /var/run/docker.sock:/var/run/docker.sock if using Docker-in-Docker.
  • Python version: Molecule requires Python 3.8+. Use actions/setup-python@v4.
  • Resource limits: Molecule with multiple platforms can consume >2GB RAM. Set runs-on: ubuntu-latest-8-cores or use --parallel carefully.
.github/workflows/ci.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
---
name: Ansible CI
on: [push, pull_request]
jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: '3.10'
      - name: Install dependencies
        run: pip install ansible-lint yamllint
      - name: Run ansible-lint
        run: ansible-lint
      - name: Run yamllint
        run: yamllint .
  test:
    needs: lint
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v3
      - uses: actions/setup-python@v4
        with:
          python-version: '3.10'
      - name: Install molecule and docker
        run: pip install molecule molecule-docker
      - name: Run molecule test
        run: molecule test
💡Pre-commit for Local Linting
Use pre-commit to run ansible-lint and yamllint before commit. This catches issues before CI. Add a .pre-commit-config.yaml with hooks for both tools.
📊 Production Insight
We had a CI pipeline that ran molecule test without lint first. A PR with a syntax error (missing colon) passed lint locally because the dev used a different yamllint config. The molecule create step failed with a cryptic YAML error. We added lint as a required job before test and enforced the same config via .ansible-lint and .yamllint in the repo.
🎯 Key Takeaway
Always run lint before test in CI; pin tool versions to avoid environment discrepancies; use caching to speed up Docker image pulls.

Common Lint Violations and Why They Matter in Production

Here are the most common ansible-lint violations and their production impact:

  1. name[missing]: Tasks without names make playbooks unreadable and break --step mode. In a production outage, you need to know exactly which task is failing. Named tasks appear in logs and alerting.
  2. no-changed-when: Commands without changed_when always report 'changed'. This breaks idempotency checks — you can't trust the 'changed' count in Ansible Tower or AWX.
  3. fqcn-builtins: Using copy instead of ansible.builtin.copy can lead to module shadowing if a collection defines a copy module. In production, this caused a role to use a community copy module that behaved differently, corrupting files.
  4. command-instead-of-shell: Using shell when command suffices introduces shell injection risks. In a production incident, a shell task with user input allowed command injection, leading to a security breach.
  5. risky-file-permissions: Setting mode: 0777 on files is a security risk. Use 0644 or 0750.
  6. no-loop: Using with_items instead of loop is deprecated. Not a security issue, but prevents using loop_control features.

To fix these, run ansible-lint --fix . (only fixes some). Review each violation manually.

tasks/main.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
---
# tasks/main.yml - common lint violations
- name: Install nginx (missing changed_when)
  ansible.builtin.apt:
    name: nginx
    state: present
  # lint: no-changed-when

- name: Restart nginx (uses command instead of service module)
  ansible.builtin.command: systemctl restart nginx
  # lint: command-instead-of-module

- name: Copy config (no mode specified)
  ansible.builtin.copy:
    src: nginx.conf
    dest: /etc/nginx/nginx.conf
  # lint: no-missing-mode
🔥Rule Severity
Rules have severity levels: very high, high, medium, low. The production profile includes all very high and high rules. Use --show-severity to see them.
📊 Production Insight
The fqcn-builtins violation once blocked a deployment because a role used service instead of ansible.builtin.service. The community.general collection was installed and its service module required extra parameters. We spent an hour debugging before ansible-lint flagged it.
🎯 Key Takeaway
Treat lint violations as production bugs; each rule prevents a real failure mode — from security breaches to idempotency breaks.

Molecule Scenarios: Testing Multiple Configurations

Molecule scenarios allow testing different configurations (e.g., different OS, different variables). Create molecule/ubuntu/molecule.yml and molecule/centos/molecule.yml. Run molecule test --scenario-name ubuntu.

Scenario structure: `` molecule/ default/ molecule.yml tests/ test_default.py ubuntu/ molecule.yml tests/ test_ubuntu.py ` Each scenario can have its own group_vars or host_vars`.

For variable testing, override variables in the scenario's molecule.yml: ``yaml provisioner: name: ansible inventory: group_vars: all: nginx_port: 8080 `` Then write tests that check port 8080.

Common use case: test the same role with different variables (e.g., ssl_enabled: true vs false). Create scenarios/ssl-enabled and scenarios/ssl-disabled.

Gotcha: Scenarios share the same tests/ directory unless you specify a custom path. Use verifier: name: testinfra directory: molecule/ubuntu/tests/ to point to scenario-specific tests.

molecule/default/molecule.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
---
# molecule/default/molecule.yml - default scenario
dependency:
  name: galaxy
driver:
  name: docker
platforms:
  - name: ubuntu2204
    image: geerlingguy/docker-ubuntu2204-ansible:latest
    pre_build_image: true
  - name: centos8
    image: geerlingguy/docker-centos8-ansible:latest
    pre_build_image: true
provisioner:
  name: ansible
verifier:
  name: testinfra
💡Scenario Inheritance
Use molecule/default as base and override only what changes in other scenarios. This reduces duplication.
📊 Production Insight
We had a role that behaved differently on Ubuntu vs CentOS due to package names. We created two scenarios and wrote separate tests. The CentOS scenario caught that we used httpd instead of apache2. Without it, the role would have broken on CentOS in production.
🎯 Key Takeaway
Use scenarios to test different OS families and variable combinations; this catches platform-specific bugs before deployment.

Advanced Molecule: Custom Drivers, Dependencies, and Pre/Post Tasks

Molecule supports drivers beyond Docker: Podman, Vagrant, EC2, GCE, Azure, and more. For production, we use the delegated driver to test against existing VMs. Example molecule.yml: ``yaml driver: name: delegated platforms: - name: my-prod-like-vm host: 10.0.0.1 `` But Docker is preferred for CI.

Dependencies are roles or collections required by your role. Define in molecule.yml: ``yaml dependency: name: galaxy options: requirements-file: requirements.yml ` Create requirements.yml: `yaml --- roles: - src: geerlingguy.nginx collections: - name: community.general ` Run molecule dependency` to install them.

Pre- and post-tasks allow running tasks before/after converge. Useful for preparing the environment (e.g., installing packages). Example: ``yaml provisioner: name: ansible playbooks: prepare: prepare.yml cleanup: cleanup.yml ` prepare.yml runs before converge; cleanup.yml` runs before destroy.

A production pattern: use prepare.yml to set up test fixtures (e.g., create a dummy database) and cleanup.yml to remove them.

molecule/custom/molecule.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
---
# molecule/custom/molecule.yml - custom driver and dependencies
dependency:
  name: galaxy
  options:
    requirements-file: requirements.yml
driver:
  name: delegated
  options:
    managed: false
    ansible_connection: local
platforms:
  - name: localhost
provisioner:
  name: ansible
  playbooks:
    prepare: prepare.yml
    converge: converge.yml
    cleanup: cleanup.yml
verifier:
  name: ansible
scenario:
  name: custom
⚠ Dependency Caching
Molecule downloads dependencies every run unless you set ANSIBLE_ROLES_PATH to a shared cache. Use ANSIBLE_ROLES_PATH=~/.ansible/roles to reuse.
📊 Production Insight
We used the delegated driver to test a role against a staging VM that mirrored production. This caught a network dependency (a firewall rule) that Docker tests missed. But it was slow. We now use Docker for unit tests and delegated for integration tests.
🎯 Key Takeaway
Use Docker for fast, isolated testing; use delegated driver for production-like integration tests; manage dependencies via requirements.yml.

Debugging Molecule Failures: A Systematic Approach

  1. Check the log: molecule test --debug prints full Ansible output. Look for fatal: lines.
  2. Isolate the step: Run molecule create, molecule converge, molecule verify separately. If converge fails, the role has a bug. If verify fails, the test is wrong.
  3. Login to container: molecule login gives a shell. Run commands manually to verify state.
  4. Check Docker logs: docker logs <container_name> if the container exits immediately.
  5. Test with a simple playbook: Create a minimal playbook to rule out role issues.
Common failures
  • molecule create fails: Docker image not found or Docker daemon not running.
  • molecule converge fails: Ansible syntax error or missing variable.
  • molecule verify fails: Test assertion fails or test file syntax error.

Example: fatal: [instance]: FAILED! => {"msg": "The task includes an option with an undefined variable."}. The variable is missing from the role's defaults or the playbook. Check molecule/default/group_vars/all/.

Another: ERROR! 'community.general' is not installed. Run molecule dependency to install collections.

For Testinfra errors, import traceback: pytest --tb=long.

debug_molecule.shBASH
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
#!/bin/bash
# Debugging molecule failures
set -euxo pipefail

# Run molecule with verbose output
molecule converge -- -vvv

# Check instance status
molecule list

# Login to instance for manual inspection
molecule login -h instance

# Run a specific test with debug
molecule verify -- -k test_nginx -x --pdb

# Clean up failed instances
molecule destroy
🔥Molecule Log Files
Molecule stores logs in ~/.cache/molecule/<role>/<scenario>/logs/. Check converge.log and verify.log for detailed output.
📊 Production Insight
We had a molecule converge failure that said 'no action detected in task'. The task used import_tasks with a relative path that didn't exist in the container. We fixed by using {{ role_path }}/tasks/.
🎯 Key Takeaway
Debug molecule step by step: use --debug, isolate phases, and log into the container to inspect state.

Idempotency Testing with Molecule: The 'converge twice' Pattern

Idempotency is critical for Ansible roles. Molecule can test it by running molecule converge twice. The first run applies the role; the second should report 'ok=0' changes. To automate, create a test: ``yaml # molecule/default/verify.yml - name: Idempotency check hosts: all gather_facts: false tasks: - name: Run role again ansible.builtin.include_role: name: myrole register: result - name: Assert no changes ansible.builtin.assert: that: - result is not changed ` Or in Testinfra: `python def test_idempotent(host): # This is tricky because Testinfra can't run roles easily. # Use host.ansible to run the role again. result = host.ansible("include_role", "name=myrole", check=False) assert result["changed"] == 0 ` Note: host.ansible` is experimental.

Common idempotency breakers
  • command/shell without changed_when.
  • lineinfile without regexp that changes the file every run.
  • template with different source every run (e.g., using date in template).

Production tip: Run idempotency test in CI for every PR. We once had a role that added a cron job every run because cron module didn't check for duplicates. Idempotency test caught it.

molecule/default/converge.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
---
# converge.yml - idempotency test pattern
- name: Converge
  hosts: all
  gather_facts: false
  tasks:
    - name: Include role
      ansible.builtin.include_role:
        name: my_role

- name: Verify idempotency (second converge)
  hosts: all
  gather_facts: false
  tasks:
    - name: Include role again
      ansible.builtin.include_role:
        name: my_role
      register: result
    - name: Fail if any changes
      ansible.builtin.fail:
        msg: "Idempotency test failed: {{ result.results | selectattr('changed', 'true') | list }}"
      when: result is changed
⚠ Idempotency with Docker
Docker containers may have different initial states (e.g., timezone). Use pre_build_image: true and a fixed image to ensure consistent results.
📊 Production Insight
We had a role that used copy with a source file that was regenerated by a previous task. The second converge always changed the file because the source had a new timestamp. We fixed by using template with force: no.
🎯 Key Takeaway
Always test idempotency by running converge twice; catch tasks that report 'changed' every run and fix them with changed_when or idempotent modules.

Advanced ansible-lint: Custom Rules and Ignoring False Positives

Sometimes ansible-lint flags code that is intentionally written that way. You can ignore rules per file or per task. Use # noqa inline: ``yaml - name: Restart service ansible.builtin.command: systemctl restart nginx changed_when: false # noqa no-changed-when ` Or per file in .ansible-lint: `yaml skip_list: - fqcn-builtins # only if you have a good reason ` Better: use warn_list to see warnings without failing: `yaml warn_list: - experimental ``

Custom rules are Python scripts that implement a matchtask function. Example: a rule that forbids using apt module without update_cache: yes. Create rules/no_apt_without_update.py: ```python from ansiblelint.rules import AnsibleLintRule

class NoAptWithoutUpdate(AnsibleLintRule): id = "custom001" shortdesc = "apt must have update_cache: yes" description = "..." tags = ["custom"]

def matchtask(self, task, file=None): if task["action"]["__ansible_module__"] == "apt" and not task["action"].get("update_cache", False): return True `` Add to .ansible-lint: `yaml rules: - ./rules ``

Production insight: We wrote a custom rule to enforce that all file tasks have owner and group set. This prevented a security incident where a file was owned by root:root instead of app:app.

.ansible-lintYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
---
# .ansible-lint - custom rules and ignoring false positives
profile: production
skip_list:
  - fqcn-builtins  # we use short module names for readability
  - no-changed-when  # command modules are fine with explicit changed_when
warn_list:
  - experimental
  - schema
use_default_rules: true
custom_rules:
  - path: /path/to/custom/rules
  - name: my-custom-rule
    description: "Custom rule to check something"
    shortcode: MY001
    severity: HIGH
    match: tasks
    regex: "some_pattern"
💡Custom Rule Performance
Custom rules run on every task, so keep them efficient. Use matchtask for task-level rules, matchplay for play-level.
📊 Production Insight
We had a false positive from risky-file-permissions on a file that needed to be world-readable. We used # noqa risky-file-permissions with a comment explaining why. This documented the exception.
🎯 Key Takeaway
Use # noqa sparingly for intentional violations; write custom rules to enforce team-specific policies.
● Production incidentPOST-MORTEMseverity: high

Silent Variable Overwrite Breaks Production Config

Symptom
Application could not authenticate to database after a routine Ansible deployment. Error: 'FATAL: password authentication failed for user "app"'
Assumption
The engineer assumed the database credentials were correctly vaulted and that the playbook passed the correct variables.
Root cause
The role had a vars/main.yml with a default value for db_password (e.g., 'changeme'). The playbook used include_role with vars but forgot to pass db_password, so the default was used, overwriting the vaulted value.
Fix
Added private: true to the role's argument spec for db_password so that if not provided, ansible-lint (with safety profile) would flag it. Also added a assert task to fail if db_password equals 'changeme'.
Key lesson
  • Always define argument specs for role parameters and use ansible-lint's safety profile to catch missing required variables.
  • Never rely on defaults for secrets.
Production debug guideSymptom → Root cause → Fix4 entries
Symptom · 01
ansible-lint exits with code 2 but no clear error message.
Fix
Run ansible-lint --verbose to see which rule is failing. Common: name[missing] if tasks lack names. Add name: to all tasks.
Symptom · 02
molecule converge succeeds but molecule verify fails on a file existence check.
Fix
The role may not have created the file. Use molecule login to inspect the container. Check the role's tasks for correct dest path. Add debug output.
Symptom · 03
molecule create fails with 'Docker connection error'.
Fix
Check if Docker is running: systemctl status docker. Ensure user is in docker group: sudo usermod -aG docker $USER. Re-login.
Symptom · 04
Testinfra test host.file('/etc/nginx/nginx.conf').exists returns False but file exists.
Fix
The test may be checking the wrong host. Verify the hostname in the test matches the container name. Use host.check_output('hostname').
★ Ansible Linting and Testing with Molecule Quick Referenceprint this for your desk
ansible-lint fails on 'name[missing]'
Immediate action
Add name to all tasks
Commands
ansible-lint --list-rules | grep name
ansible-lint --fix .
Fix now
Add name: "Describe task" to each task.
molecule test hangs on create+
Immediate action
Check Docker daemon
Commands
docker ps
molecule create --debug
Fix now
Restart Docker: sudo systemctl restart docker.
verify test fails on service status+
Immediate action
SSH into container
Commands
molecule login
systemctl status nginx
Fix now
Ensure service is enabled: systemctl enable nginx in role.
yamllint error: line too long+
Immediate action
Check .yamllint config
Commands
yamllint -d '{"extends": "default", "rules": {"line-length": {"max": 120}}}' .
Fix now
Add line-length: {max: 120} to .yamllint.
CI fails on molecule test but passes locally+
Immediate action
Check CI Docker version
Commands
docker --version
pip list | grep molecule
Fix now
Pin molecule version in requirements.txt: molecule==6.0.3
Ansible Linting and Testing Tools Comparison
ToolPurposeConfiguration FileKey Command
ansible-lintStatic analysis of Ansible code.ansible-lintansible-lint --profile production .
yamllintYAML syntax and style checking.yamllintyamllint .
MoleculeRole testing lifecyclemolecule.ymlmolecule test
TestinfraPython-based test assertionstests/test_default.pymolecule verify
Ansible VerifierAnsible playbook-based testsverify.ymlmolecule verify
⚙ Quick Reference
11 commands from this guide
FileCommand / CodePurpose
.ansible-lintprofile: productionWhy ansible-lint Profiles Matter
.yamllintextends: defaultyamllint
molecule.ymldependency:Molecule with Docker Driver
test_default.pydef test_nginx_is_installed(host):Writing Testinfra Verify Tests
verify.yml- name: Verify nginx installationAnsible Verify Tests
.githubworkflowsci.ymlname: Ansible CIIntegrating Lint and Molecule in CI/CD
tasksmain.yml- name: Install nginx (missing changed_when)Common Lint Violations and Why They Matter in Production
moleculedefaultmolecule.ymldependency:Molecule Scenarios
moleculecustommolecule.ymldependency:Advanced Molecule
debug_molecule.shset -euxo pipefailDebugging Molecule Failures
moleculedefaultconverge.yml- name: ConvergeIdempotency Testing with Molecule

Key takeaways

1
Use ansible-lint with the 'production' profile in CI to enforce strict rules that prevent real production failures.
2
yamllint prevents YAML parsing errors; enforce consistent indentation and line length in .yamllint.
3
Molecule with Docker driver provides fast, isolated role testing; always use pre-built images with pre_build_image
true.
4
Write Testinfra tests for fast, Pythonic assertions; use Ansible verifier for complex module-based checks.
5
Always run lint before test in CI to fail fast; pin tool versions to avoid environment discrepancies.
6
Test idempotency by running converge twice; fix tasks that always report 'changed'.
7
Use Molecule scenarios to test multiple OS families and variable combinations.
8
Write custom ansible-lint rules to enforce team-specific policies; use # noqa sparingly for intentional violations.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
What is the difference between ansible-lint profiles 'min' and 'producti...
Q02SENIOR
How do you test an Ansible role against multiple Linux distributions usi...
Q03JUNIOR
What is the purpose of 'pre_build_image: true' in Molecule?
Q04JUNIOR
How do you write a Testinfra test to check that a service is running and...
Q05SENIOR
What is the 'fqcn-builtins' rule and why is it important?
Q06SENIOR
How do you ignore a specific ansible-lint rule for a single task?
Q07JUNIOR
What is the Molecule lifecycle and what commands correspond to each phas...
Q08SENIOR
How do you test idempotency with Molecule?
Q01 of 08SENIOR

What is the difference between ansible-lint profiles 'min' and 'production'?

ANSWER
'min' includes only syntax and basic sanity rules. 'production' includes all rules from 'safety' profile plus requirements like name[missing], no-changed-when, and fqcn-builtins. 'production' is the strictest profile intended for production-ready code.
FAQ · 8 QUESTIONS

Frequently Asked Questions

01
What is the difference between ansible-lint and yamllint?
02
Can I use Molecule without Docker?
03
How do I debug a failing Molecule test?
04
What is a common reason for molecule converge to fail?
05
How do I test a role that requires systemd?
06
What is the purpose of the 'no-changed-when' rule?
07
Can I run Molecule tests in parallel?
08
How do I enforce a custom policy (e.g., no hardcoded passwords) with ansible-lint?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 16, 2026
last updated
2,406
articles · all by Naren
🔥

That's Ansible. Mark it forged?

8 min read · try the examples if you haven't

Previous
Ansible Performance Tuning
21 / 37 · Ansible
Next
Ansible Windows Automation