Skip to content

How to Structure an Ansible Collection

Ansible

I recently wrapped up work on an Ansible collection I developed for a customer through my role at Broadcom. With the project complete, I wanted to write down the structure it followed and the reasons behind it. This guide is for developers building their first collection and maintainers checking an existing one against Ansible's recommended practices.

Those decisions matter once an Ansible repository has been in use for a while. Roles sit beside playbooks that depend on custom filters. Lookup plugins get copied from one repository to another. The same automation has to run locally and in Ansible Automation Platform (AAP), where repository-specific plugin paths become difficult to maintain.

A collection gives that content a package name and a standard layout. Roles, plugins, playbooks, documentation, and tests can ship together as a versioned package under a namespace such as example.platform. Once you understand where each part belongs, the structure is straightforward.

Why the Structure Matters

In example.platform.service_cluster, example is the namespace, platform is the collection, and service_cluster is the role or plugin name. Together, they form a fully qualified collection name, or FQCN. That name tells you where the content comes from. A loose service_cluster role buried in a playbook repository doesn't.

Collections also simplify distribution. Users can install one from Automation Hub, Galaxy, a Git repository, or a built artifact without configuring custom plugin paths.

Custom modules go under plugins/modules/, filters under plugins/filter/, and roles under roles/. Ansible can then resolve the content from the installed collection instead of depending on a developer's shell configuration.

The Blueprint

A development checkout can sit beneath an ansible_collections directory, grouped first by namespace and then by collection name:

ansible_collections/
└── example/
    └── platform/
        ├── README.md
        ├── galaxy.yml
        ├── meta/
        │   └── runtime.yml
        ├── docs/
        │   ├── architecture.md
        │   ├── migration.md
        │   └── operations.md
        ├── roles/
        │   ├── bootstrap/
        │   │   ├── defaults/
        │   │   │   └── main.yml
        │   │   ├── handlers/
        │   │   │   └── main.yml
        │   │   ├── tasks/
        │   │   │   └── main.yml
        │   │   ├── templates/
        │   │   └── vars/
        │   │       └── main.yml
        │   └── hardening/
        │       ├── defaults/
        │       │   └── main.yml
        │       └── tasks/
        │           └── main.yml
        ├── plugins/
        │   ├── modules/
        │   │   └── platform_node.py
        │   ├── module_utils/
        │   │   └── api.py
        │   ├── filter/
        │   │   └── normalize_names.py
        │   ├── lookup/
        │   │   └── platform_secret.py
        │   ├── inventory/
        │   │   └── platform_inventory.py
        │   └── action/
        │       └── platform_request.py
        ├── playbooks/
        │   ├── bootstrap.yml
        │   └── rotate_certificates.yml
        ├── tests/
        │   ├── integration/
        │   │   └── targets/
        │   │       └── platform_node/
        │   │           └── tasks/
        │   │               └── main.yml
        │   └── unit/
        │       └── plugins/
        │           └── modules/
        │               └── test_platform_node.py
        ├── extensions/
        │   └── molecule/
        │       ├── config.yml
        │       └── default/
        │           ├── converge.yml
        │           └── molecule.yml
        └── changelogs/
            ├── changelog.yaml
            └── fragments/

The collection root is the directory containing galaxy.yml, often the repository root. ansible-galaxy collection build packages the included content, and installation places that content under ansible_collections/<namespace>/<collection>. The built artifact uses MANIFEST.json and FILES.json; it doesn't include the source galaxy.yml.

For development, ansible-test expects the collection root at a path ending in ansible_collections/<namespace>/<collection>. The checkout can live there directly, or you can place a symlink to it there. A common workspace looks like this:

~/src/collections/
└── ansible_collections/
    └── example/
        └── platform/
            ├── galaxy.yml
            └── ...

You won't need every directory on day one. Create a directory when you have content for it. Empty scaffolding doesn't make the collection more complete.

Core Components

galaxy.yml

galaxy.yml contains the metadata used to build and publish the collection. Keep it plain. Someone reading it should be able to tell what they're installing, where the source lives, and which other collections it requires.

namespace: example
name: platform
version: 1.3.0
readme: README.md
authors:
  - Platform Engineering <[email protected]>
description: Shared automation for Example platform operations.
license:
  - MIT
tags:
  - infrastructure
  - automation
  - platform
dependencies:
  community.general: ">=8.0.0"
repository: https://github.com/example/ansible-collection-platform
documentation: https://github.com/example/ansible-collection-platform/tree/main/docs
issues: https://github.com/example/ansible-collection-platform/issues

Treat namespace and name as part of the public interface. Changing either one changes every FQCN in the collection. The version matters to dependency resolution and release automation, so update it deliberately too.

meta/runtime.yml

meta/runtime.yml controls collection runtime behavior. Its simplest common use is declaring the minimum supported Ansible version:

requires_ansible: ">=2.16.0"

Set requires_ansible to the oldest ansible-core release you test and support. ansible-galaxy uses it when selecting compatible collection versions, and Ansible checks it when loading the collection.

The same file can redirect renamed plugins, deprecate content, or mark removed content with a tombstone. You may not need any of that in a new collection, but it becomes useful once people depend on old names.

roles/

Legacy roles usually move into roles/ with their internal layout mostly intact:

roles/
└── bootstrap/
    ├── defaults/
    │   └── main.yml
    ├── handlers/
    │   └── main.yml
    ├── tasks/
    │   └── main.yml
    ├── templates/
    └── vars/
        └── main.yml

The call site changes:

- name: Bootstrap platform nodes
  hosts: platform_nodes
  roles:
    - example.platform.bootstrap

For an existing role, move it first and leave its behavior alone. Rename a hyphenated role directory if necessary, update its references, and stop there. Changing variables, defaults, and handlers in the same pull request makes it much harder to tell whether the collection migration broke anything.

For shared role dependencies in roles/<role_name>/meta/main.yml, use fully qualified names:

dependencies:
  - role: example.platform.hardening
  - role: example.platform.ca_trust

Roles inside a collection can refer to other roles in that collection by short name. I still use the FQCN for shared automation because the dependency remains clear when the role is read outside its original repository.

If a role dependency comes from another collection, list that collection and its version constraint under dependencies in galaxy.yml. Referencing an external role in meta/main.yml doesn't install the collection that provides it.

plugins/

The plugins/ directory holds Python code that extends Ansible. Each plugin type has its own subdirectory.

Ansible modules belong in plugins/modules/:

- name: Register a platform node
  example.platform.platform_node:
    name: app-01
    state: present

Shared Python helpers belong in plugins/module_utils/. Import them from your modules:

from ansible_collections.example.platform.plugins.module_utils.api import PlatformClient

Keep shared API clients, argument helpers, and common validation code in module_utils/. Otherwise the copies begin to drift as soon as authentication or error handling changes.

Filters get their own plugin type:

- name: Normalize a service name
  ansible.builtin.debug:
    msg: "{{ service_name | example.platform.normalize_names }}"

Lookups do too:

- name: Read a platform secret
  ansible.builtin.debug:
    msg: "{{ lookup('example.platform.platform_secret', 'db/password') }}"
  no_log: true

If Ansible loads it as plugin code, put it under the matching plugins/ subdirectory. Directories such as library/, filter_plugins/, and lookup_plugins/ still make sense for code that's deliberately local to a playbook repository or standalone role. Roles inside a collection must move their plugins into the collection's plugins/ tree.

playbooks/

A collection can ship playbooks as well as roles and plugins. This works well for repeatable operations such as bootstrapping a service, rotating certificates, or validating a platform:

playbooks/
├── bootstrap.yml
├── rotate_certificates.yml
└── validate_platform.yml

Call them by FQCN:

ansible-playbook example.platform.bootstrap

Ansible resolves that to playbooks/bootstrap.yml inside the installed collection.

Put a repeatable operation in the collection when it should travel with the roles and plugins it uses. Keep environment-specific inventory, credentials, and site policy outside.

docs/

Keep installation steps, requirements, and one short example in README.md. Longer design, migration, and operating notes belong in docs/.

Cover the questions maintainers and users will need answered:

  • Which roles are supported entry points?
  • Which modules call external APIs?
  • Which variables are public, and which are internal?
  • How should users migrate from the old role repository?
  • What inventory does the collection expect?

Record those answers while the implementation decisions are still fresh.

tests/ and Molecule

Collections contain several kinds of content, so they need more than one kind of test. Unit tests cover Python logic. Integration tests check that Ansible can load and execute plugins. Molecule is useful for exercising role behavior against test instances.

Start with tests/unit/ for Python plugin logic that can run without touching infrastructure. Use tests/integration/targets/ when you need Ansible to load and execute the collection as Ansible content. The blueprint above shows both locations.

Run these checks from the collection root. Remember that the root must be beneath the ansible_collections/<namespace>/<collection> path described earlier:

ansible-test sanity
ansible-test units
ansible-test integration

Current Molecule releases use extensions/molecule/ for collection scenarios:

extensions/
└── molecule/
    ├── config.yml
    └── default/
        ├── converge.yml
        └── molecule.yml

Use Molecule when a role needs a disposable test instance. A converge scenario only covers the paths it executes, so add idempotence checks and verification tasks for the state that matters.

Refactoring a Messy Repository

Decide the Release Boundary

Before moving files, decide what the collection owns and who releases it. Group content that shares maintainers, versioning, tests, and users. Split content when different teams release it independently or when installing one part would pull in unrelated dependencies.

Move Behavior Before Improving Behavior

Keep the first migration small:

  1. From the workspace's ansible_collections/ directory, create the collection skeleton with ansible-galaxy collection init example.platform.
  2. Move one role and the plugins it directly needs.
  3. Update call sites to use fully qualified collection names.
  4. Add a Molecule scenario or integration target that covers representative existing behavior.
  5. Release a small version and make one consumer use it.

Do that before cleaning up old variable names or reorganizing tasks. Once the pull request mixes packaging changes with behavior changes, reviewers have a much harder time spotting regressions.

Remove Hidden Load Paths

Search for custom plugin paths in ansible.cfg, CI variables, Makefiles, and job templates:

rg -n "ANSIBLE_LIBRARY|ANSIBLE_FILTER_PLUGINS|ANSIBLE_LOOKUP_PLUGINS|library|filter_plugins|lookup_plugins"

Hidden paths often fail when a playbook moves to a different workstation or automation controller. Move shared plugin code into the collection and update users to use its FQCN.

Version What Users Depend On

Once other repositories depend on the collection, version it consistently and maintain a changelog. Deprecate public roles, plugins, and variables before removing them. Users shouldn't have to read the commit history to work out whether an upgrade will break a playbook.

Build the artifact the same way users will install it:

ansible-galaxy collection build
ansible-galaxy collection install example-platform-1.3.0.tar.gz

Keep the Boundary Clear

The collection should contain the content your team versions and releases together. That usually means reusable roles, plugins, operational playbooks, tests, and documentation. Inventory, credentials, and environment policy stay with the repositories that own those environments.

Install the built artifact in a clean environment and run it there. If it still depends on a shell profile, custom plugin path, or undocumented inventory variable, the migration isn't done.

References