Introduction

ansible-lint is the standard static analysis tool for Ansible playbooks, roles, and collections. It catches bugs, enforces best practices, flags deprecated syntax, and can automatically fix many issues. Used locally during development and in CI/CD pipelines, ansible-lint ensures consistent, high-quality automation code across your team. This guide covers installation, configuration, every rule category, autofix, custom rules, and CI/CD integration.

Install

# pip (recommended — latest version)
pip install ansible-lint

# RHEL/CentOS
sudo dnf install ansible-lint

# With all optional checkers
pip install ansible-lint[community,yamllint]

# Verify
ansible-lint --version

Quick Start

# Lint a playbook
ansible-lint site.yml

# Lint an entire directory
ansible-lint roles/

# Lint with verbose output
ansible-lint -v site.yml

# List all rules
ansible-lint -L

# List rules with tags
ansible-lint -T

# Auto-fix what can be fixed
ansible-lint --fix site.yml

# Dry-run autofix (show what would change)
ansible-lint --fix=diff site.yml

Configuration

.ansible-lint

# .ansible-lint (project root)
---
profile: production  # null, min, basic, moderate, safety, shared, production

# Paths to lint
exclude_paths:
  - .github/
  - .cache/
  - molecule/

# Skip specific rules
skip_list:
  - yaml[line-length]
  - no-changed-when

# Treat warnings as errors
strict: true

# Enable optional rules
enable_list:
  - no-log-password
  - no-same-owner

# Offline mode (skip Galaxy checks)
offline: true

# Mock roles/modules that aren't installed
mock_roles:
  - external_role

mock_modules:
  - custom_module

# Custom rules directory
# rulesdir:
#   - custom_rules/

Profiles

ProfileRulesUse Case
nullNo rulesDisable linting
minSyntax onlyMinimal checking
basicCommon issuesStarting point
moderate+ Best practicesTeam projects
safety+ SecurityProduction code
shared+ ConsistencyShared roles/collections
productionAll rulesProduction automation

Rule Categories

args — Module Argument Validation

Validates that module arguments are correct and match expected types.

# ❌ Wrong argument
- ansible.builtin.file:
    path: /tmp/test
    state: directory
    mode: 755           # Should be string '0755'

# ✅ Correct
- ansible.builtin.file:
    path: /tmp/test
    state: directory
    mode: '0755'

command-shell — Command Module Best Practices

# ❌ command-instead-of-module: use apt module
- ansible.builtin.command: apt-get install nginx

# ✅ Use the module
- ansible.builtin.apt:
    name: nginx
    state: present

# ❌ command-instead-of-shell: doesn't need shell
- ansible.builtin.shell: cat /etc/hostname

# ✅ Use command (no shell features needed)
- ansible.builtin.command: cat /etc/hostname

deprecated — Deprecated Syntax and Modules

# ❌ deprecated-bare-vars
- ansible.builtin.debug:
    msg: "{{ item }}"
  with_items: mylist     # Bare variable

# ✅ Use proper variable syntax
- ansible.builtin.debug:
    msg: "{{ item }}"
  loop: "{{ mylist }}"

# ❌ deprecated-local-action
- local_action: ansible.builtin.command echo hello

# ✅ Use delegate_to
- ansible.builtin.command: echo hello
  delegate_to: localhost

# ❌ deprecated-module
- ansible.builtin.include:
    file: tasks.yml

# ✅ Use current module
- ansible.builtin.include_tasks:
    file: tasks.yml

formatting — YAML Style

# ❌ yaml[truthy]: use true/false not yes/no
become: yes

# ✅ Use boolean
become: true

# ❌ yaml[line-length]: line too long (>160 chars)
- name: This is an extremely long task name that goes on and on and on and describes every little thing about what this task does in excruciating detail

# ✅ Keep lines reasonable
- name: Install web server packages

# ❌ no-tabs
- name: Task with	tabs

# ✅ Use spaces
- name: Task with spaces

idempotency — Ensure Repeatable Runs

# ❌ no-changed-when: command always reports changed
- name: Check disk space
  ansible.builtin.command: df -h

# ✅ Mark as never changing
- name: Check disk space
  ansible.builtin.command: df -h
  changed_when: false

# ✅ Or set proper change detection
- name: Create user
  ansible.builtin.command: useradd testuser
  register: result
  changed_when: result.rc == 0
  failed_when: result.rc not in [0, 9]

naming — Consistent Naming

# ❌ name[missing]: task without name
- ansible.builtin.apt:
    name: nginx

# ✅ Always name tasks
- name: Install nginx
  ansible.builtin.apt:
    name: nginx

# ❌ name[casing]: should start with uppercase
- name: install nginx
  ansible.builtin.apt:
    name: nginx

# ✅ Capitalize task names
- name: Install nginx
  ansible.builtin.apt:
    name: nginx

# ❌ role-name: invalid characters
# Role: My-Role_v2!

# ✅ Lowercase with underscores
# Role: my_role_v2

security — Sensitive Data

# ❌ no-log-password: password visible in logs
- name: Set user password
  ansible.builtin.user:
    name: admin
    password: "{{ vault_password }}"

# ✅ Add no_log
- name: Set user password
  ansible.builtin.user:
    name: admin
    password: "{{ vault_password }}"
  no_log: true

unpredictability — Avoid Surprises

# ❌ ignore-errors: hides real failures
- name: Install package
  ansible.builtin.apt:
    name: maybe-exists
  ignore_errors: true

# ✅ Use failed_when for specific conditions
- name: Install package
  ansible.builtin.apt:
    name: maybe-exists
  register: result
  failed_when:
    - result.rc != 0
    - "'not found' not in result.msg"

# ❌ partial-become: become_user without become
- name: Run as postgres
  ansible.builtin.command: psql -c "SELECT 1"
  become_user: postgres

# ✅ Include become
- name: Run as postgres
  ansible.builtin.command: psql -c "SELECT 1"
  become: true
  become_user: postgres

fqcn — Fully Qualified Collection Names

# ❌ fqcn[action-core]: not using FQCN
- copy:
    src: file.txt
    dest: /tmp/file.txt

# ✅ Use FQCN
- ansible.builtin.copy:
    src: file.txt
    dest: /tmp/file.txt

jinja — Template Best Practices

# ❌ jinja[spacing]: missing spaces in braces
- name: Debug
  ansible.builtin.debug:
    msg: "{{variable}}"

# ✅ Add spaces
- name: Debug
  ansible.builtin.debug:
    msg: "{{ variable }}"

# ❌ jinja[invalid]: invalid Jinja2
- name: Debug
  ansible.builtin.debug:
    msg: "{{ variable | no_such_filter }}"

key-order — Consistent Task Key Ordering

# ❌ key-order: inconsistent key order
- become: true
  name: Install package
  ansible.builtin.apt:
    name: nginx

# ✅ Standard order: name → module → parameters → modifiers
- name: Install package
  ansible.builtin.apt:
    name: nginx
  become: true

Autofix

ansible-lint can automatically fix many issues:

# Fix all auto-fixable issues
ansible-lint --fix .

# Preview fixes without applying
ansible-lint --fix=diff .

# Fix specific file
ansible-lint --fix roles/webserver/tasks/main.yml

Auto-fixable Rules

  • yaml[truthy] — yes/no → true/false
  • fqcn[action-core] — short names → FQCN
  • jinja[spacing] — {{x}} → {{ x }}
  • name[casing] — lowercase → capitalize first letter
  • deprecated-local-action — local_action → delegate_to
  • key-order — reorder task keys
  • no-free-form — free-form → structured syntax

CI/CD Integration

GitHub Actions

# .github/workflows/lint.yml
name: Ansible Lint
on: [push, pull_request]

jobs:
  lint:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4

      - name: Run ansible-lint
        uses: ansible/ansible-lint@main

GitLab CI

ansible-lint:
  image: python:3.12
  before_script:
    - pip install ansible-lint
  script:
    - ansible-lint --strict .

Pre-commit Hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/ansible/ansible-lint
    rev: v24.10.0
    hooks:
      - id: ansible-lint
        additional_dependencies:
          - ansible-core>=2.17
pip install pre-commit
pre-commit install

Custom Rules

# custom_rules/no_hardcoded_ips.py
from ansiblelint.rules import AnsibleLintRule

class NoHardcodedIPs(AnsibleLintRule):
    id = "custom-no-hardcoded-ip"
    shortdesc = "Do not hardcode IP addresses"
    description = "IP addresses should be in variables or inventory, not hardcoded in tasks"
    tags = ["custom", "security"]

    def matchtask(self, task, file=None):
        import re
        ip_pattern = r'\b\d{1,3}\.\d{1,3}\.\d{1,3}\.\d{1,3}\b'
        for key, value in task.get("action", {}).items():
            if isinstance(value, str) and re.search(ip_pattern, value):
                if value not in ("127.0.0.1", "0.0.0.0"):
                    return True
        return False
# .ansible-lint
rulesdir:
  - custom_rules/

Common Fixes Cheat Sheet

RuleFix
yaml[truthy]yes → true, no → false
fqcn[action-core]copy: → ansible.builtin.copy:
no-changed-whenAdd changed_when: false to commands
name[missing]Add name: to every task
jinja[spacing]{{x}} → {{ x }}
command-instead-of-moduleReplace command: apt install with apt: module
deprecated-bare-varswith_items: list → loop: "{{ list }}"
key-orderPut name first, then module, then modifiers
no-log-passwordAdd no_log: true to password tasks
ignore-errorsUse failed_when: instead

Conclusion

ansible-lint enforces consistent, high-quality Ansible code by catching bugs, deprecated syntax, security issues, and style violations before they reach production. Use the production profile for the strictest checking, --fix for automatic corrections, and integrate into CI/CD pipelines so every commit is validated. Start with ansible-lint --fix . on your existing codebase to clean up the low-hanging fruit, then enforce strict mode in CI to keep it clean.