Introduction

ansible-lint checks playbooks, roles, and collections against best practices and known anti-patterns. It catches errors before they hit production: deprecated syntax, missing FQCN, unsafe shell commands, missing changed_when, and more.

Install

# pip (recommended)
pip install ansible-lint

# pipx (isolated)
pipx install ansible-lint

# Verify
ansible-lint --version

Basic Usage

# Lint a playbook
ansible-lint playbook.yml

# Lint all YAML in directory
ansible-lint

# Lint specific files
ansible-lint roles/webserver/tasks/main.yml

# Show all rules
ansible-lint -L

# List only rule tags
ansible-lint -T

Common Rules and Fixes

name[missing] — Tasks Need Names

# ❌ Bad
- apt:
    name: nginx

# ✅ Good
- name: Install nginx
  ansible.builtin.apt:
    name: nginx

fqcn[action-core] — Use Fully Qualified Collection Names

# ❌ Bad
- copy:
    src: app.conf
    dest: /etc/myapp/

# ✅ Good
- ansible.builtin.copy:
    src: app.conf
    dest: /etc/myapp/

command-instead-of-module — Use Modules

# ❌ Bad
- ansible.builtin.command: chown root:root /etc/app.conf

# ✅ Good
- ansible.builtin.file:
    path: /etc/app.conf
    owner: root
    group: root

no-changed-when — shell/command Need changed_when

# ❌ Bad
- ansible.builtin.shell: cat /etc/hostname

# ✅ Good
- ansible.builtin.shell: cat /etc/hostname
  changed_when: false

# ✅ Also good
- ansible.builtin.shell: deploy.sh
  register: deploy
  changed_when: "'deployed' in deploy.stdout"

yaml[truthy] — Boolean Values

# ❌ Bad
become: yes
enabled: True

# ✅ Good
become: true
enabled: true

risky-shell-pipe — Pipe Error Handling

# ❌ Bad (pipe hides errors)
- shell: grep "error" /var/log/app.log | wc -l

# ✅ Good (set pipefail)
- shell: set -o pipefail && grep "error" /var/log/app.log | wc -l
  args:
    executable: /bin/bash

jinja[spacing] — Jinja2 Whitespace

# ❌ Bad
msg: "Hello {{name}}"

# ✅ Good
msg: "Hello {{ name }}"

Configuration

.ansible-lint

# .ansible-lint
---
# Exclude paths
exclude_paths:
  - .github/
  - molecule/
  - tests/

# Skip specific rules
skip_list:
  - yaml[line-length]      # Allow long lines
  - name[casing]           # Allow flexible naming

# Warn instead of fail
warn_list:
  - experimental            # Don't fail on experimental rules
  - role-name[path]

# Enable optional rules
enable_list:
  - no-same-owner
  - no-tabs

# Use specific profile
profile: production    # null, min, basic, moderate, safety, shared, production

# Mock roles/modules not installed
mock_roles:
  - geerlingguy.docker
  - geerlingguy.nginx

mock_modules:
  - custom_module

Profiles

ProfileStrictnessUse Case
nullNoneJust parse, no rules
minMinimalBasic syntax only
basicLowCommon mistakes
moderateMediumTeam projects
safetyHighSecurity-focused
sharedHighPublished roles
productionHighestProduction code
# .ansible-lint
profile: moderate    # Good starting point

Inline Skip

# Skip for single task
- name: Run custom script
  ansible.builtin.shell: /opt/scripts/deploy.sh  # noqa: command-instead-of-shell
  changed_when: false

# Skip for block
- name: Legacy tasks  # noqa: fqcn[action-core]
  copy:
    src: old.conf
    dest: /etc/old.conf

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
      - uses: ansible/ansible-lint@main

GitLab CI

ansible-lint:
  image: python:3.12
  script:
    - pip install ansible-lint
    - ansible-lint

Pre-commit Hook

# .pre-commit-config.yaml
repos:
  - repo: https://github.com/ansible/ansible-lint
    rev: v24.7.0
    hooks:
      - id: ansible-lint

Fix Automatically

# Auto-fix what it can
ansible-lint --fix

# Auto-fix specific file
ansible-lint --fix playbook.yml

# Show what would be fixed (dry run)
ansible-lint --fix -x

Custom Rules

# rules/check_tags.py
from ansiblelint.rules import AnsibleLintRule

class CheckTagsRule(AnsibleLintRule):
    id = "custom-tags"
    shortdesc = "All tasks must have tags"
    description = "Every task should have at least one tag for selective execution"
    severity = "MEDIUM"
    tags = ["custom"]

    def matchtask(self, task, file=None):
        if not task.get("tags"):
            return True
        return False
# Use custom rules
ansible-lint -R -r rules/ playbook.yml

Common Workflow

# 1. Run lint
ansible-lint

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

# 3. Review remaining issues
ansible-lint -f codeclimate  # Machine-readable output

# 4. Skip false positives with inline comments
# 5. Update .ansible-lint for project-wide skips

Conclusion

ansible-lint catches problems before they reach production. Start with profile: moderate, add it to CI/CD, and address warnings incrementally. Key rules to prioritize: use FQCN (ansible.builtin.*), name every task, add changed_when to shell/command tasks, and use modules over raw commands. The --fix flag handles many issues automatically. Over time, increase the profile strictness as your codebase improves.