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_whenon all command/shell tasks -
no_log: trueon sensitive tasks -
validateon config file deployments - Handlers for service restarts (not inline restarts)
-
requirements.ymlpins collection versions -
ansible-lintpasses -
--check --diffproduces sensible output
Related Articles
- Ansible Roles Guide
- Ansible roles vs collections
- Ansible check mode vs diff mode
- Ansible ansible-lint Guide
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.