Directory Structure #
As an agentless automation tool, Ansible gives developers a very high degree of freedom in organizing project files. You can put all playbook instructions, server inventory lists, and configuration files in a single flat folder, and Ansible will still execute them without issue. However, this unstructured approach is a dangerous shortcut. When your automation project starts managing dozens of environments, hundreds of variables, and various server roles, an unstructured project turns into tangled code that’s impossible to maintain collaboratively. This article discusses the importance of standardizing file layout, compares a testing structure with the industry-standard production structure, and breaks down the important components within them.
Why Standardize the Directory Structure #
In software engineering and infrastructure as code (Infrastructure as Code / IaC), standardizing the directory structure plays a role equal to writing clean code. Here are the main reasons you should adopt a standard directory structure:
- Smooth Team Collaboration: When all platform engineering team members understand where variable files, tasks, and external libraries live, code reviews and new feature integration run much faster.
- Project Readability and Navigation: New developers joining the team can navigate files immediately without having to guess where a particular variable file is defined.
- Separation of Concerns: Separating environment data (server IPs, credentials) from automation logic (how to install Nginx, how to configure a database) ensures you can modify target infrastructure without risking damage to the main automation logic.
- Information Security: By clearly separating environment variable files, you can apply encryption policies (using Ansible Vault) to sensitive files specifically, without having to encrypt the entire playbook workflow.
Minimal Structure for Testing #
For early learning purposes, technology demonstrations (proof of concept), or small-scale ad-hoc task testing, a simple one-level structure is enough. This layout is very compact and easy to understand.
ansible-project-minimal/
├── ansible.cfg # Local project configuration file
├── inventory.ini # List of managed node IP addresses
└── playbook.yml # Main instruction file (tasks)
This minimal structure is great for getting quick executions started. However, you are strongly forbidden from bringing this structure to an enterprise-level production environment because it lacks the modularity needed for scalability.
Standard Enterprise Production Structure #
For medium to large production-scale projects that manage multi-type systems (like web servers, databases, load balancers) across various environments (like dev, staging, production), the Ansible community recommends the structured layout below:
ansible-project/
│
├── ansible.cfg # Project-level Ansible configuration
├── requirements.yml # External role/collection dependency management
│
├── environments/ # Inventory separated by environment
│ ├── development/
│ │ ├── hosts.ini # Development environment servers
│ │ ├── group_vars/ # Group variables for development
│ │ │ ├── all.yml
│ │ │ └── webservers.yml
│ │ └── host_vars/ # Development-specific host variables
│ │ └── dev-web-01.yml
│ │
│ └── production/
│ ├── hosts.ini # Production environment servers
│ ├── group_vars/ # Group variables for production (Vault-encrypted)
│ │ ├── all.yml
│ │ └── webservers.yml
│ └── host_vars/
│ └── prod-web-01.yml
│
├── playbooks/ # Main playbook files folder
│ ├── site.yml # Master playbook (runs the entire infrastructure)
│ ├── webservers.yml # Web server tier-specific playbook
│ ├── dbservers.yml # Database tier-specific playbook
│ └── app-deployment.yml # Application release deployment playbook
│
├── roles/ # Folder of modular, self-contained units
│ ├── common/ # Base setup (SSH, NTP, Security Hardening)
│ ├── nginx/ # Reverse proxy configuration
│ └── postgresql/ # Database cluster configuration
│
├── files/ # Static files (SSL certs, banners, public keys)
│ └── app-logos/
│
└── templates/ # Dynamic Jinja2 files (.j2)
└── nginx.conf.j2
Detailed Breakdown of Each Component #
Let’s break down the function and technical rules of each directory component above so you can implement them correctly.
1. The ansible.cfg File #
Placed at the root of the project directory. The goal is that every time you run an Ansible command from this folder, this project’s local configuration is used — not the system global /etc/ansible/ansible.cfg.
2. The environments/ Directory #
Industry best practice is to physically separate inventory (hosts) files by environment. By separating the development and production hosts.ini files into different folders, you reduce the risk of command execution errors (like accidentally deploying a test script to production servers).
When running automation, you’re required to specify the inventory path explicitly:
# Execute changes to the staging environment
ansible-playbook -i environments/development/hosts.ini playbooks/webservers.yml
# Execute changes to the production environment with the Vault key
ansible-playbook -i environments/production/hosts.ini playbooks/webservers.yml --ask-vault-pass
3. The playbooks/ Directory #
This is the container folder for your main workflow files. Instead of making one giant monolithic playbook file that handles everything, you’re recommended to split it into small function-based parts.
The master playbook site.yml acts as an aggregator that calls other sub-playbooks using the import module:
# File: playbooks/site.yml
---
# Import the base server setup workflow
- import_playbook: common-setup.yml
# Import the web server setup workflow
- import_playbook: webservers.yml
# Import the database setup workflow
- import_playbook: dbservers.yml
Managing group_vars and host_vars Variables #
Ansible has a very smart variable auto-loading feature through two special directories: group_vars/ and host_vars/.
group_vars/: This directory contains variable files intended for specific server groups. The file names inside this folder must exactly match the server group names you define in thehosts.iniinventory file. Theall.ymlfile is a special file whose variables are inherited by every server in the inventory.host_vars/: Contains variable files specific to one particular server. The file name must match the host name (FQDN or IP alias) in the inventory file.
Variable Resolution and Precedence Rules #
One important technical point that often confuses developers is: Where should we put the group_vars and host_vars directories?
Ansible automatically looks for the group_vars and host_vars folders in two places:
- Playbook-adjacent: Alongside the main playbook file being executed.
- Inventory-adjacent: Alongside the inventory file (
hosts.ini) in use at that moment.
If a variable with the same name exists in both places, the variable near the inventory file (inventory-adjacent) has higher priority and overrides the variable near the playbook file.
The variable override hierarchy by directory location is illustrated in the diagram below (lowest priority at the top to highest priority at the bottom):
flowchart TD
subgraph "Project-Level Variables (Playbook-adjacent)"
A["group_vars/all.yml (Playbook)"] -->|"Overridden by"| B["group_vars/webservers.yml (Playbook)"]
end
subgraph "Environment-Level Variables (Inventory-adjacent)"
B -->|"Overridden by"| C["environments/production/group_vars/all.yml"]
C -->|"Overridden by"| D["environments/production/group_vars/webservers.yml"]
end
subgraph "Host-Specific Variables"
D -->|"Overridden by"| E["host_vars/prod-web-01.yml (Playbook)"]
E -->|"Overridden by"| F["environments/production/host_vars/prod-web-01.yml"]
end
subgraph "Dynamic & CLI Variables (Highest)"
F -->|"Overridden by"| G["Playbook Internal (vars / vars_files)"]
G -->|"Overridden by"| H["CLI Arguments (--extra-vars)"]
endComplete Anatomy of an Ansible Role Structure #
Roles are the main modularization unit in Ansible. Roles let you package tasks, variables, templates, handlers, and files into one self-contained, reusable package across different automation projects.
When a playbook calls a role (for example roles: [ nginx ]), Ansible automatically looks for instruction files in that role’s subdirectories based on standard naming conventions.
Here’s the ideal anatomy of the nginx role folder:
roles/nginx/
├── defaults/
│ └── main.yml # Default variables (lowest priority, easily overridden)
│
├── vars/
│ └── main.yml # Internal role variables (high priority, users shouldn't change)
│
├── tasks/
│ └── main.yml # Main task list to be executed
│
├── handlers/
│ └── main.yml # Triggers for specific actions (like restarting the nginx service)
│
├── files/
│ └── block-ips.conf # Static files copied as-is to managed nodes
│
├── templates/
│ └── nginx.conf.j2 # Dynamic Jinja2-based configuration templates
│
├── meta/
│ └── main.yml # Role metadata (author, license, and other role dependencies)
│
└── tests/ # Standalone local test scripts for this role
├── inventory
└── test.yml
The internal execution flow when Ansible processes a role is illustrated in the following chart:
flowchart TD
A["Start Running site.yml"] --> B["Read the ansible.cfg file"]
B --> C["Load Inventory (environments/production/hosts.ini)"]
C --> D["Load Variables (group_vars/ and host_vars/)"]
D --> E["Run the Main Playbook"]
E --> F{"Does the playbook call a role?"}
F -- "Yes" --> G["Enter the roles/nginx/ folder"]
G --> H["Load defaults/main.yml (Lowest Priority)"]
H --> I["Load vars/main.yml (Role Internal Variables)"]
I --> J["Execute tasks/main.yml (Work Instructions)"]
J --> K{"Does a task trigger Notify?"}
K -- "Yes" --> L["Execute handlers/main.yml at the end of the Play"]
K -- "No" --> M["Continue to the next Task"]
F -- "No" --> N["Execute Playbook Tasks Linearly"]Managing Third-Party Dependencies with requirements.yml #
When your project needs complex automation (like installing a fully clustered database server with monitoring), you don’t have to build that role from scratch. You can download stable community-built roles or collections available on Ansible Galaxy.
To document and manage versions of these third-party libraries, you use a file called requirements.yml in the project’s main directory.
Example requirements.yml File Format
#
# File: requirements.yml
---
# Section 1: Downloading third-party Roles
roles:
# Download the Nginx configuration role from geerlingguy version 3.2.0
- name: geerlingguy.nginx
version: "3.2.0"
# Download the PostgreSQL role from an external Git repository link
- src: https://github.com/geerlingguy/ansible-role-postgresql.git
scm: git
version: "main"
name: custom.postgresql
# Section 2: Downloading third-party Collections
collections:
# Download AWS modules for cloud computing needs
- name: amazon.aws
version: "6.1.0"
# Download community Kubernetes modules
- name: kubernetes.core
version: "2.4.0"
To automatically install all the dependencies listed in the file above before running your playbook, run the following commands in the terminal:
# Install all roles into the local roles/ directory
ansible-galaxy role install -r requirements.yml -p ./roles/
# Install all collections
ansible-galaxy collection install -r requirements.yml
Anti-Pattern vs Best Practice Design Comparison #
To make the file layout easier to understand, here’s a side-by-side comparison of a wrong design (anti-pattern) and a correct design (best practice) in managing Ansible project folders.
// ANTI-PATTERN: Mixing everything in the main folder (Monolithic & Messy)
ansible-project-messy/
├── hosts.ini # Combined dev and prod inventory (Very Dangerous)
├── ansible.cfg
├── setup-nginx-db-app.yml # One playbook containing hundreds of mixed tasks
├── nginx.conf # Static configuration file without environment variation
├── variables.yml # All variables mixed together (dev, prod, db, nginx)
└── db-key.pem # Sensitive credentials stored in plain text without encryption
// BEST PRACTICE: Modular, Isolated, and Using Encryption (Safe & Scalable)
ansible-project-clean/
├── ansible.cfg
├── requirements.yml
├── environments/ # Physically isolated environments
│ ├── staging/
│ │ ├── hosts.ini
│ │ └── group_vars/
│ │ └── webservers.yml
│ └── production/
│ ├── hosts.ini
│ └── group_vars/
│ └── webservers.yml # Sensitive variables encrypted with Ansible Vault
├── playbooks/ # Separate modular playbooks by tier
│ ├── site.yml
│ ├── webservers.yml
│ └── dbservers.yml
├── roles/ # Uses reusable self-contained roles
│ ├── common/
│ ├── nginx/
│ └── postgresql/
└── group_vars/ # Project-level global variables
└── all.yml
Summary #
- Project Modularity — Organizing a project with a standardized directory structure clearly separates declarative playbook logic from inventory data and sensitive variables.
- Physical Environment Separation — Storing inventory in separate subdirectories (like
environments/development/andenvironments/production/) avoids the fatal risk of executing against the wrong target servers.- group_vars & host_vars Hierarchy — Ansible reads group/host variables automatically. Variables near the inventory (inventory-adjacent) have higher priority and override variables near the playbook (playbook-adjacent).
- Ansible Role Structure — Roles divide automation functionality into standard folders like
tasks/,defaults/,vars/,templates/, andhandlers/for easy portability across projects.- defaults vs vars in Roles — Put variables users may adjust in the
defaults/main.ymlfolder (lowest priority), and lock internal system variables invars/main.yml(high priority).- requirements.yml — Use this file to document and automate downloading external roles and collections from Ansible Galaxy using the
ansible-galaxycommand.- Isolated Vault Encryption — Separate secret files into specific group_vars to be encrypted with Ansible Vault, without having to change the main task folders.