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
| Profile | Rules | Use Case |
|---|---|---|
null | No rules | Disable linting |
min | Syntax only | Minimal checking |
basic | Common issues | Starting point |
moderate | + Best practices | Team projects |
safety | + Security | Production code |
shared | + Consistency | Shared roles/collections |
production | All rules | Production 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/falsefqcn[action-core]— short names → FQCNjinja[spacing]—{{x}}→{{ x }}name[casing]— lowercase → capitalize first letterdeprecated-local-action—local_action→delegate_tokey-order— reorder task keysno-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
| Rule | Fix |
|---|---|
yaml[truthy] | yes → true, no → false |
fqcn[action-core] | copy: → ansible.builtin.copy: |
no-changed-when | Add changed_when: false to commands |
name[missing] | Add name: to every task |
jinja[spacing] | {{x}} → {{ x }} |
command-instead-of-module | Replace command: apt install with apt: module |
deprecated-bare-vars | with_items: list → loop: "{{ list }}" |
key-order | Put name first, then module, then modifiers |
no-log-password | Add no_log: true to password tasks |
ignore-errors | Use failed_when: instead |
Links
Related Articles
- Ansible Molecule Testing
- Ansible Playbook Guide
- Ansible Roles Guide
- Ansible CI/CD with GitHub Actions
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.