Introduction

Well-structured Ansible projects are easier to debug, review, and hand off. Poorly structured ones become unmaintainable fast. This guide covers the practices that matter most — from directory layout to naming to testing — based on real production experience.

Directory Structure

ansible-project/
├── ansible.cfg                 # Project-level config
├── site.yml                    # Main entry point
├── playbooks/
│   ├── webservers.yml
│   ├── dbservers.yml
│   └── monitoring.yml
├── roles/
│   ├── common/
│   │   ├── defaults/main.yml
│   │   ├── tasks/main.yml
│   │   ├── handlers/main.yml
│   │   ├── templates/
│   │   └── files/
│   ├── nginx/
│   └── postgresql/
├── inventory/
│   ├── production/
│   │   ├── hosts.yml
│   │   ├── group_vars/
│   │   │   ├── all/
│   │   │   │   ├── vars.yml
│   │   │   │   └── vault.yml
│   │   │   └── webservers.yml
│   │   └── host_vars/
│   └── staging/
├── collections/
│   └── requirements.yml
├── scripts/                    # Helper scripts
├── docs/                       # Project documentation
└── .ansible-lint               # Linter configuration

Naming Conventions

Task Names

# ❌ Bad — no names, unclear names
- apt: name=nginx
- command: systemctl restart nginx
- copy: src=config dest=/etc/app/

# ✅ Good — descriptive, starts with verb
- name: Install nginx web server
  ansible.builtin.apt:
    name: nginx
    state: present

- name: Restart nginx after config change
  ansible.builtin.service:
    name: nginx
    state: restarted

- name: Deploy application configuration
  ansible.builtin.copy:
    src: app.conf
    dest: /etc/app/config.yml

Use FQCN (Fully Qualified Collection Name)

# ❌ Short names — ambiguous
- apt: name=nginx
- template: src=config.j2 dest=/etc/app.conf
- service: name=nginx state=started

# ✅ FQCN — explicit, future-proof
- ansible.builtin.apt:
    name: nginx
- ansible.builtin.template:
    src: config.j2
    dest: /etc/app.conf
- ansible.builtin.service:
    name: nginx
    state: started

Variables

# ❌ Ambiguous variable names
port: 8080
name: myapp
path: /opt/app

# ✅ Prefixed, descriptive names
app_http_port: 8080
app_name: myapp
app_install_path: /opt/app

# ✅ Role variables prefixed with role name
nginx_worker_connections: 1024
nginx_client_max_body_size: "10m"
postgresql_max_connections: 200

Idempotency

# ❌ Not idempotent — runs every time, always "changed"
- name: Create directory
  ansible.builtin.command: mkdir -p /opt/app

# ✅ Idempotent — only changes when needed
- name: Create directory
  ansible.builtin.file:
    path: /opt/app
    state: directory
    mode: "0755"

# ❌ Not idempotent
- name: Add cron job
  ansible.builtin.command: >
    crontab -l | { cat; echo "0 2 * * * /opt/backup.sh"; } | crontab -

# ✅ Idempotent
- name: Add backup cron job
  ansible.builtin.cron:
    name: "Daily backup"
    hour: "2"
    minute: "0"
    job: "/opt/backup.sh"

# When you must use command/shell, make it idempotent
- name: Initialize database
  ansible.builtin.command: /opt/app/init-db.sh
  args:
    creates: /opt/app/.db-initialized

Security

# ✅ Never hardcode secrets
- ansible.builtin.template:
    src: config.j2
    dest: /etc/app/config.yml
  vars:
    db_password: "{{ vault_db_password }}"  # From vault

# ✅ Use no_log for sensitive tasks
- name: Set database password
  ansible.builtin.command: >
    psql -c "ALTER USER app PASSWORD '{{ db_password }}'"
  no_log: true

# ✅ Restrict file permissions
- name: Deploy private key
  ansible.builtin.copy:
    src: app.key
    dest: /etc/ssl/private/app.key
    owner: root
    mode: "0600"

# ✅ Validate before applying
- name: Deploy sudoers
  ansible.builtin.template:
    src: sudoers.j2
    dest: /etc/sudoers.d/app
    validate: "visudo -cf %s"

Error Handling

# ✅ Use block/rescue for critical operations
- block:
    - name: Deploy application
      ansible.builtin.include_role:
        name: deploy
  rescue:
    - name: Rollback on failure
      ansible.builtin.include_role:
        name: rollback
  always:
    - name: Send notification
      ansible.builtin.uri:
        url: "{{ slack_webhook }}"
        body: '{"text": "Deploy {{ "succeeded" if not ansible_failed_task else "failed" }}"}'

# ✅ Validate required variables early
- name: Pre-flight checks
  ansible.builtin.assert:
    that:
      - app_version is defined
      - db_host is defined
      - deploy_user is defined
    fail_msg: "Missing required variables"

Performance

# ✅ Pass lists to modules (one call instead of N)
- ansible.builtin.apt:
    name:
      - nginx
      - python3
      - redis-server
    state: present

# ❌ Loop creates N separate calls
- ansible.builtin.apt:
    name: "{{ item }}"
  loop: [nginx, python3, redis-server]

# ✅ Limit fact gathering
- hosts: webservers
  gather_facts: true
  gather_subset:
    - network
    - hardware

# ✅ Use pipelining
# ansible.cfg
# [connection]
# pipelining = True

# ✅ Use async for long-running tasks
- name: Run backup (may take 30 minutes)
  ansible.builtin.command: /opt/backup.sh
  async: 3600
  poll: 60

Testing

# Syntax check
ansible-playbook site.yml --syntax-check

# Lint
ansible-lint site.yml

# Dry run
ansible-playbook site.yml --check --diff

# Limit to one host first
ansible-playbook site.yml --limit web1.example.com

# Step through tasks
ansible-playbook site.yml --step

ansible-lint Configuration

# .ansible-lint
skip_list:
  - yaml[line-length]    # Some long lines are fine
  - name[casing]

warn_list:
  - experimental

enable_list:
  - fqcn
  - no-changed-when

Documentation

# ✅ Document your playbook at the top
# site.yml
#
# Main entry point for infrastructure management.
#
# Usage:
#   ansible-playbook site.yml -i inventory/production
#   ansible-playbook site.yml -i inventory/staging --tags webservers
#
# Required variables:
#   - app_version: Application version to deploy
#   - env: Target environment (production/staging)
#
# Tags:
#   - common: Base system setup
#   - webservers: Web server deployment
#   - database: Database setup
---
- import_playbook: playbooks/common.yml
- import_playbook: playbooks/webservers.yml

Checklist

  • Every task has a descriptive name:
  • All modules use FQCN
  • Variables are prefixed (role or app name)
  • Secrets are vault-encrypted
  • changed_when on all command/shell tasks
  • no_log: true on sensitive tasks
  • validate on config file deployments
  • Handlers for service restarts (not inline restarts)
  • requirements.yml pins collection versions
  • ansible-lint passes
  • --check --diff produces sensible output

Conclusion

Good Ansible projects follow simple principles: name everything, use FQCN, keep it idempotent, encrypt secrets, validate configs, and test before applying. The directory structure (roles + inventory + group_vars) scales from 10 hosts to 10,000. The checklist at the end of this article is your code review guide — if every item is checked, your playbooks are production-ready.