Structure #

In the world of infrastructure automation with Ansible, consistency is the key to scalability. One of Ansible’s beauties is how it forces you to organize code through standardized directory structure conventions. When you first see a role’s folder structure, you might feel there are too many subfolders to manage. However, each subfolder is designed with a very specific purpose to support the separation of concerns principle.

Understanding role directory structure anatomy isn’t just about memorizing folder names — it’s about understanding how Ansible leverages these folders to automatically find assets when a playbook runs. With a well-organized structure, different development teams can collaborate on the same automation repository without confusion, because everyone knows exactly where to look for tasks, templates, static files, or variables.


Deep Exploration of Role Subdirectories #

Let’s explore every standard subdirectory inside a role. We’ll discuss in depth what should be stored there, the recommended naming rules, and how these components interact with each other.

1. The tasks/ Directory #

The tasks/ directory is the brain of every role. This is where you define the concrete steps to run on managed nodes.

  • main.yml: This is the mandatory file inside the tasks/ directory. It acts as the execution entry point.
  • Sub-files (Task Modularization): For complex roles, it’s highly recommended to split tasks into several separate files like install.yml for package installation, configure.yml for configuration file setup, and service.yml for managing the service lifecycle. You combine these files into main.yml using import_tasks or include_tasks.
  • Best Practice: Avoid writing overly generic tasks. Give every task a descriptive name so Ansible execution logs are easy to read.

2. The handlers/ Directory #

Handlers are special tasks that only run when triggered (notified) by other tasks that experienced a change status. The handlers/ directory stores the main.yml file containing definitions of all handlers for that role.

  • Main Function: Most often used to restart or reload system services after a configuration file is updated.
  • Merging Mechanism: Use the listen feature on modern handlers to group several actions under one event generator, making your code cleaner and more modular.

3. The templates/ Directory #

The templates/ directory holds template files using the Jinja2 templating engine. Files in this directory usually have the .j2 extension.

  • Working Principle: Ansible reads the template file, evaluates the Jinja2 variables inside it based on the runtime environment context, then produces a rendered output file on the managed node through the template module.
  • Naming Rules: Always name your template files after the target file name on the managed node with a .j2 suffix. Example: nginx.conf.j2 to generate the file /etc/nginx/nginx.conf.

4. The files/ Directory #

Unlike templates/, the files/ directory is used to store static files you want to copy directly to managed nodes without any modification.

  • When to Use It: Use it to copy images, static web assets (like favicon.ico or static HTML files), binary configuration files, or helper scripts (like bash scripts) that don’t need dynamic variable filling.
  • Performance Advantage: Static file transfer using the copy module is faster than processing Jinja2 templates because it doesn’t require parsing overhead on the control node.

5. The defaults/ Directory #

The defaults/ directory contains the main.yml file defining variables with the lowest priority.

  • Key Role: This is the “API” of your role. You document all variables customizable by role users here, complete with safe default values.
  • Override Level: Because of its low priority, variables here can easily be overridden through inventory, group vars, or directly in the playbook.

6. The vars/ Directory #

The vars/ directory contains the main.yml file defining role internal variables with high priority.

  • Key Role: Used to store constant internal data for the role, like default installation paths for different operating systems or specific package dependency lists.
  • Security: Prevents role users from accidentally overriding important variables from outside the role.

7. The meta/ Directory #

The meta/ directory contains the main.yml file defining role metadata.

  • Metadata Information: Contains the author name, license (for example BSD or MIT), supported operating system platforms (for example Ubuntu, CentOS), and Galaxy categories.
  • Dependencies: Here you also declare other roles that are prerequisites (dependencies) for this role to run correctly.

8. The tests/ Directory #

The tests/ directory is used to store test files to make sure your role works as expected.

  • Standard Contents: Usually contains a dummy inventory file and a test.yml test playbook calling the role with default parameters.
  • Molecule: In modern development, this directory is often used by the Molecule framework for automated testing using Docker containers.

Automatic Lookup Rules Relative to the Role Path #

One of Ansible’s smartest features when running roles is the automatic path lookup mechanism. When you use modules like copy, template, script, include_tasks, or even vars_files inside a role task, you don’t need to write absolute paths or long relative paths from the project root. Ansible intelligently looks for those assets in the appropriate role subdirectory.

Let’s study Ansible’s path resolution lookup rules in order:

  1. Local Lookup Inside the Role:

    • If you call the copy module with the src: website.html parameter, Ansible first looks for that file in the files/ folder of the running role (roles/<role_name>/files/website.html).
    • If you call the template module with the src: vhost.conf.j2 parameter, Ansible looks in roles/<role_name>/templates/vhost.conf.j2.
  2. Lookup Relative to the Playbook:

    • If the file isn’t found inside the role’s internal directories, Ansible goes up one level and looks in the directory relative to the main playbook (.yml) file location currently running.
  3. Lookup in the Global Search Path:

    • If still not found, Ansible checks the global search paths defined in your ansible.cfg configuration.

To clarify this search resolution workflow, let’s look at the following flowchart:

flowchart TD
    A["Start: Task calls an asset (e.g. src: 'config.conf')"] --> B{"Is the task running inside a Role?"}
    B -- "Yes" --> C{"Does the asset exist in the Role's internal folder? (files/ or templates/)"}
    C -- "Yes" --> D["Use the asset from the Role's internal directory"]
    C -- "No" --> E{"Does the asset exist in the directory relative to the main Playbook?"}
    B -- "No" --> E
    E -- "Yes" --> F["Use the asset from the Playbook-relative directory"]
    E -- "No" --> G{"Does the asset exist in the global path (ansible.cfg)?"}
    G -- "Yes" --> H["Use the asset from the global path"]
    G -- "No" --> I["Error: Asset not found! Execution stopped."]

Practical Consequences of the Lookup Rules #

This automatic lookup mechanism brings important consequences to your code writing:

  • High Portability: You can move a role directory to another repository or change the role’s placement location in the file system without updating asset search paths inside your role tasks.
  • Isolated Namespace: Static files with the same name (for example nginx.conf) in the nginx role won’t collide with the nginx.conf file in the common role because Ansible prioritizes each role’s own local folder first.

Optimal Templates and Files Usage Strategy #

One of the most frequent design decisions when building roles is: should we store configuration in files/ or in templates/?

Although both look similar because they both function to deliver files to managed nodes, mixing their usage without a clear strategy can make your role rigid or hard to customize.

Let’s discuss the optimal determination strategy:

1. When to Use files/ (Static Files) #

Use the files/ directory only if the file being delivered is 100% static and identical across all target hosts, regardless of operating system variations, environment (development vs production), or custom variables.

  • Case Examples:
    • SSH public key files (authorized_keys).
    • Company logos or image assets for default web pages.
    • System maintenance scripts (bash/python scripts) whose parameters are controlled via command-line arguments, not hardcoded inside the script.
    • Public root CA SSL certificates distributed to all servers.

2. When to Use templates/ (Dynamic Jinja2 Files) #

Use the templates/ directory if the file contents need to adapt dynamically based on target machine conditions or user configuration preferences.

  • Case Examples:
    • Service configuration files (like nginx.conf or postgresql.conf) that need to adjust the worker_processes count to the managed node’s physical CPU count (ansible_processor_vcpus).
    • Application configuration containing unique database credentials for each environment.
    • Virtual host configuration files whose domain names come from playbook variables.

Let’s compare the tactical implementation differences of both approaches through the following code example:

# roles/webserver/tasks/configure.yml
# CORRECT: Using static files for static assets and templates for dynamic configuration
---
- name: Copy the default static HTML file to the web server
  copy:
    src: welcome_page.html     # Automatically taken from roles/webserver/files/welcome_page.html
    dest: /var/www/html/index.html
    owner: www-data
    mode: '0644'

- name: Render the Nginx configuration template dynamically
  template:
    src: nginx.conf.j2        # Automatically taken from roles/webserver/templates/nginx.conf.j2
    dest: /etc/nginx/nginx.conf
    owner: root
    mode: '0644'

Custom Plugins: Extending Functionality with Library and Lookup Plugins #

Sometimes, built-in Ansible modules aren’t enough to handle your very specific automation tasks. For example, you might need to interact with a complex internal company API or do custom data transformations that are hard to do using only built-in Jinja2 filters.

Ansible allows you to include custom plugins and custom modules directly inside your role. This is a remarkable feature because it makes your role truly self-contained.

1. Custom Modules in the library/ Directory #

If you place a Python file inside your role’s library/ directory (for example library/my_custom_api.py), Ansible automatically loads that file as a module callable directly inside your role tasks.

# roles/myapp/tasks/main.yml
# CORRECT: Calling the custom module located in our role's library/ directory
---
- name: Interact with the internal API using a custom module
  my_custom_api:
    api_endpoint: "https://api.internal.corp"
    action: "register_node"
    node_name: "{{ ansible_hostname }}"
  register: api_result

2. Custom Lookup Plugins in the lookup_plugins/ Directory #

Lookup plugins are used to fetch data from external sources on the control node. By placing a custom plugin in the lookup_plugins/ directory, you can write your own data lookup logic in python, like reading secrets from an internal password manager.

# roles/myapp/tasks/main.yml
# CORRECT: Using a custom lookup plugin from the lookup_plugins/ directory
---
- name: Fetch database credentials from the internal password manager
  set_fact:
    db_password: "{{ lookup('internal_vault', 'db_prod_password_key') }}"

By leveraging these custom directories, you don’t need to ask system administrators to install additional modules globally on the control node. Just distribute your role, and all custom functionality works immediately.


Case Study: Anti-Pattern vs Best Practice Directory Structures #

To deepen your understanding of how to organize role directory structures professionally, let’s study a real case comparison between a bad directory structure (anti-pattern) and a good directory structure (best practice).

1. The Bad Directory Structure (Anti-Pattern) #

Imagine a rushed team creating a role for their web application. They don’t split files correctly and mix various asset types in the wrong places.

# ANTI-PATTERN: A messy, hard-to-maintain role directory structure
roles/bad_app/
  ├── tasks/
  │   └── main.yml          # Contains 300 lines of mixed tasks (install, config, deploy)
  ├── files/
  │   ├── app.conf          # Hardcoded configuration file (hard to customize)
  │   ├── setup_script.sh   # Giant bash script that should be written with Ansible modules
  │   └── db_password.txt   # Secret file stored raw (security vulnerability)
  └── defaults/
      └── main.yml          # Empty (no variable API for role users)

Why is this bad?

  • Monolithic: All tasks are piled into one tasks/main.yml file. Very hard to trace failures or debug.
  • Rigid: Uses the static app.conf file in the files/ folder containing hardcoded parameters. If you want to change the database name on production servers, you have to edit this file directly, breaking code consistency.
  • Security Hazard: Storing secret files like db_password.txt in plaintext without Ansible Vault encryption.
  • Hidden Logic: Relying on an external bash script (setup_script.sh) for configuration instead of leveraging the declarative advantages of built-in Ansible modules.

2. The Good Directory Structure (Best Practice) #

Now, let’s see how to redesign the role above by applying all the good Ansible directory structure principles.

# CORRECT: A clean, segregated, and flexible role directory structure
roles/good_app/
  ├── tasks/
  │   ├── main.yml          # Clean main orchestrator
  │   ├── install.yml       # Specifically for apt/yum package installation
  │   ├── configure.yml     # Specifically for configuration template rendering
  │   └── deploy.yml        # Specifically for git code repository pulls
  ├── templates/
  │   └── app.conf.j2       # Dynamic configuration based on Jinja2 variables
  ├── defaults/
  │   └── main.yml          # Contains the complete default variable list & documentation
  ├── vars/
  │   └── main.yml          # Stores internal system constants (like directory paths)
  ├── handlers/
  │   └── main.yml          # Centralized handlers for application service restarts
  └── README.md             # Complete variable parameter guide and usage instructions

Why is this very good?

  • Clear Logic Separation: Each task file in the tasks/ folder is only responsible for one application lifecycle phase.
  • Very Flexible: The configuration file is defined as the app.conf.j2 template. All sensitive parameters like database hosts or ports are read from variables whose values can be set through inventory or Ansible Vault securely.
  • Living Documentation: The defaults/main.yml file clearly documents which variables role users can override.
  • Integration Ease: Comes with centralized handlers to ensure service restarts only happen when configuration files truly change, saving server downtime.

Summary #

  • Every role subdirectory has a specific role: tasks/ for logic automation, handlers/ for reactive actions, templates/ for dynamic configuration, and files/ for static files.
  • Ansible applies automatic path lookup that prioritizes the role’s internal directories (files/ and templates/) before looking in the main playbook-relative directories.
  • Use templates/ with Jinja2 .j2 for files needing dynamic parameters, and limit files/ to truly static assets across all environments.
  • You can independently extend role capabilities by including custom python modules in the library/ directory and custom lookup plugins in the lookup_plugins/ directory.
  • Avoid anti-patterns like piling all tasks into one giant file or storing sensitive credentials in plaintext in the role’s files folder.

← Previous: What is a Role? Next: Dependency →

About | Author | Content Scope | Editorial Policy | Privacy Policy | Disclaimer | Contact