Ansible Playbook Structure — Project Layout Best Practices
Introduction
A well-organized Ansible project is the difference between maintainable automation and spaghetti YAML. This guide covers the recommended directory structure from single playbooks to enterprise-scale projects with multiple environments, roles, and collections.
Minimal Project
project/
├── ansible.cfg
├── inventory.yml
└── site.yml
# ansible.cfg
[defaults]
inventory = inventory.yml
roles_path = roles
retry_files_enabled = false
stdout_callback = yaml
[privilege_escalation]
become = true
become_method = sudo
# inventory.yml
all:
children:
webservers:
hosts:
web01:
ansible_host: 192.168.1.10
web02:
ansible_host: 192.168.1.11
databases:
hosts:
db01:
ansible_host: 192.168.1.20
Standard Project Layout
project/
├── ansible.cfg
├── requirements.yml # Galaxy roles/collections
├── site.yml # Master playbook
├── webservers.yml # Per-group playbook
├── databases.yml
│
├── inventory/
│ ├── production/
│ │ ├── hosts.yml
│ │ ├── group_vars/
│ │ │ ├── all.yml
│ │ │ ├── webservers.yml
│ │ │ └── databases.yml
│ │ └── host_vars/
│ │ └── db01.yml
│ └── staging/
│ ├── hosts.yml
│ └── group_vars/
│ └── all.yml
│
├── roles/
│ ├── common/
│ │ ├── defaults/main.yml
│ │ ├── handlers/main.yml
│ │ ├── tasks/main.yml
│ │ ├── templates/
│ │ └── files/
│ ├── nginx/
│ └── postgresql/
│
├── files/ # Global files
├── templates/ # Global templates
└── filter_plugins/ # Custom filters
Inventory Organization
Single Environment
# inventory.yml
all:
vars:
ansible_user: deploy
ansible_python_interpreter: /usr/bin/python3
children:
webservers:
hosts:
web[01:10]:
databases:
hosts:
db[01:03]:
loadbalancers:
hosts:
lb01:
lb02:
Multi-Environment
# Use different inventories per environment
ansible-playbook site.yml -i inventory/staging/
ansible-playbook site.yml -i inventory/production/
# inventory/production/group_vars/all.yml
env: production
domain: app.example.com
nginx_worker_connections: 4096
db_pool_size: 50
# inventory/staging/group_vars/all.yml
env: staging
domain: dev.example.com
nginx_worker_connections: 1024
db_pool_size: 10
Group Variables
# inventory/production/group_vars/webservers.yml
nginx_version: "1.26"
nginx_worker_processes: auto
nginx_sites:
- name: app
server_name: "{{ domain }}"
root: /var/www/app
ssl: true
# inventory/production/group_vars/databases.yml
postgresql_version: "16"
postgresql_max_connections: 200
postgresql_shared_buffers: "4GB"
Encrypted Variables
# Create encrypted file
ansible-vault create inventory/production/group_vars/databases/vault.yml
# inventory/production/group_vars/databases/vault.yml
vault_db_password: "s3cret!"
vault_replication_password: "r3pl!ca"
# inventory/production/group_vars/databases/main.yml (unencrypted)
db_password: "{{ vault_db_password }}"
replication_password: "{{ vault_replication_password }}"
Playbook Organization
Master Playbook (site.yml)
---
# site.yml — master playbook
- name: Apply common configuration
hosts: all
roles:
- common
- name: Configure web servers
hosts: webservers
roles:
- nginx
- app
- name: Configure databases
hosts: databases
roles:
- postgresql
- backup
Per-Group Playbooks
# webservers.yml — run only web server tasks
---
- name: Configure web servers
hosts: webservers
roles:
- common
- nginx
- app
Task-Specific Playbooks
# deploy.yml — application deployment only
---
- name: Deploy application
hosts: webservers
serial: "25%"
tasks:
- name: Pull latest code
ansible.builtin.git:
repo: https://github.com/org/app.git
dest: /opt/app
version: "{{ app_version | default('main') }}"
- name: Restart application
ansible.builtin.systemd:
name: app
state: restarted
Role Structure
roles/nginx/
├── defaults/
│ └── main.yml # Default variables (lowest priority)
├── vars/
│ └── main.yml # Role variables (higher priority)
├── tasks/
│ ├── main.yml # Entry point
│ ├── install.yml
│ ├── configure.yml
│ └── service.yml
├── handlers/
│ └── main.yml # Service restart handlers
├── templates/
│ ├── nginx.conf.j2
│ └── site.conf.j2
├── files/
│ └── ssl-params.conf
├── meta/
│ └── main.yml # Dependencies and metadata
└── README.md
# roles/nginx/tasks/main.yml
---
- name: Install nginx
ansible.builtin.import_tasks: install.yml
tags: [nginx, install]
- name: Configure nginx
ansible.builtin.import_tasks: configure.yml
tags: [nginx, configure]
- name: Manage nginx service
ansible.builtin.import_tasks: service.yml
tags: [nginx, service]
# roles/nginx/meta/main.yml
---
dependencies:
- role: common
- role: ssl
when: nginx_ssl_enabled | default(false)
galaxy_info:
author: Your Name
description: Nginx web server
license: MIT
min_ansible_version: "2.15"
platforms:
- name: Ubuntu
versions: [22.04, 24.04]
- name: EL
versions: [8, 9]
Collections Requirements
# requirements.yml
---
roles:
- name: geerlingguy.docker
version: "7.4.0"
collections:
- name: community.general
version: ">=9.0.0"
- name: ansible.posix
version: ">=1.5.0"
- name: community.postgresql
# Install dependencies
ansible-galaxy install -r requirements.yml
ansible-galaxy collection install -r requirements.yml
Enterprise Layout
ansible-infra/
├── ansible.cfg
├── Makefile # Common commands
├── requirements.yml
│
├── playbooks/
│ ├── site.yml
│ ├── deploy.yml
│ ├── rollback.yml
│ ├── security-audit.yml
│ └── disaster-recovery.yml
│
├── inventory/
│ ├── production/
│ ├── staging/
│ ├── development/
│ └── dynamic/
│ └── aws_ec2.yml # Dynamic inventory plugin
│
├── roles/
│ ├── internal/ # Custom roles
│ │ ├── common/
│ │ ├── app/
│ │ └── monitoring/
│ └── external/ # Galaxy roles (gitignored)
│
├── collections/ # Local collections
│
├── plugins/
│ ├── filter/
│ ├── callback/
│ └── inventory/
│
├── files/ # Global shared files
├── templates/ # Global shared templates
│
├── tests/
│ ├── molecule/ # Role tests
│ └── integration/ # Playbook tests
│
├── docs/
│ └── runbooks/
│
├── .github/
│ └── workflows/
│ ├── lint.yml
│ └── test.yml
│
├── .ansible-lint # Linting config
├── .yamllint # YAML linting
└── .gitignore
Makefile for Common Commands
.PHONY: lint test deploy
lint:
ansible-lint playbooks/
yamllint .
test:
ansible-playbook playbooks/site.yml --check --diff -i inventory/staging/
deploy-staging:
ansible-playbook playbooks/deploy.yml -i inventory/staging/
deploy-production:
ansible-playbook playbooks/deploy.yml -i inventory/production/ --ask-vault-pass
requirements:
ansible-galaxy install -r requirements.yml --force
ansible-galaxy collection install -r requirements.yml --force
.gitignore
# Ansible
*.retry
roles/external/
collections/ansible_collections/
# Vault
*.vault
vault_password
# Secrets
*.pem
*.key
# OS
.DS_Store
*.swp
Troubleshooting
| Issue | Solution |
|---|---|
| Role not found | Check roles_path in ansible.cfg |
| Variable not defined | Check precedence: defaults < group_vars < host_vars < playbook |
| Wrong inventory | Verify with ansible-inventory -i inventory/prod/ --list |
| Vault password | Use --vault-password-file or ANSIBLE_VAULT_PASSWORD_FILE |
Best Practices
- One role per concern —
nginx,postgresql,monitoring, notserver-setup - Environment parity — same roles, different variables per environment
- Encrypt secrets —
ansible-vaultfor passwords, keys, tokens - Pin versions — lock Galaxy roles and collections in
requirements.yml - Use tags — enable selective execution of tasks
- Lint everything —
ansible-lintandyamllintin CI/CD - Document — README per role, runbooks for operations
Conclusion
Good project structure scales from a single playbook to hundreds of roles across multiple environments. Start simple, add structure as complexity grows, and keep your automation maintainable with clear separation between roles, inventory, and playbooks.