Home DevOps Building Custom Ansible Collections: Ship Your Own Modules Without the Pain
Advanced 3 min · July 11, 2026

Building Custom Ansible Collections: Ship Your Own Modules Without the Pain

Build custom Ansible collections that survive production.

N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Production
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
Before you start⏱ 35 min
  • Ansible 2.10+ installed. Python 3.8+ for module development. Basic Python programming knowledge.
 ● Production Incident 🔎 Debug Guide ⚙ Triage Commands
Quick Answer

Create a directory structure with galaxy.yml, write your Python modules in plugins/modules/, add a requirements.yml for dependencies, then build with ansible-galaxy collection build. Publish to Galaxy or use locally with collections/.

✦ Definition~90s read
What is Building Custom Ansible Collections?

A custom Ansible collection is a distributable package of modules, roles, plugins, and playbooks. It lets you bundle your automation logic into a reusable, versioned artifact that your team or the community can install via Ansible Galaxy.

Think of a collection like a LEGO set.
Plain-English First

Think of a collection like a LEGO set. Instead of hunting for individual bricks (modules) all over the floor, you get a box with all the pieces you need, plus instructions (roles) and extra parts (plugins). You can share the box with your team, and everyone builds the same thing without missing pieces.

You've been there. A custom module that lives in a random Git repo, copied into every playbook with a library/ directory. Then someone updates it, breaks something, and you spend an hour tracking down which version is running where. That's the problem collections solve — they give you a single source of truth with versioning, dependencies, and a clean install path. After this article, you'll be able to build, test, and ship a custom collection that your team can consume with a single ansible-galaxy collection install command.

Why Bother? The Pain of Ad-Hoc Modules

Before collections, every team had their own hack. Some used library/ directories in playbook repos. Others symlinked a shared drive. The result: version hell. You'd fix a bug in one module, push a playbook, and three other playbooks would break because they depended on the old behavior. Collections fix this by giving you a package manager for your automation. You get semantic versioning, dependency resolution, and a single install command. No more hunting for where a module lives.

before_collections.devopsDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
# io.thecodeforge — DevOps tutorial

# The old way: library/ directory in playbook repo
# playbook.yml
- hosts: all
  tasks:
    - name: Use custom module
      my_custom_module:
        param1: value

# library/my_custom_module.py lives in the same repo
# Every playbook repo has its own copy -> version drift
Output
No output — this is a structural example.
Production Trap:
Never use library/ in a shared playbook repo. You'll lose track of which version is deployed where. Collections are the only sane way to distribute custom modules.

Anatomy of a Collection: What Goes Where

A collection is just a directory with a specific structure. The galaxy.yml manifest is your metadata — namespace, name, version, dependencies. Modules live in plugins/modules/, roles in roles/, playbooks in playbooks/. The plugins/ directory also holds action plugins, filters, and more. The key insight: module_utils is for shared Python code between your modules. Don't put utility functions in each module — that's how you get copy-paste bugs.

collection_structure.devopsDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
# io.thecodeforge — DevOps tutorial

# Example collection directory tree
my_collection/
├── galaxy.yml                # Manifest — required
├── README.md                 # Document what this does
├── requirements.yml          # Dependencies (optional)
├── plugins/
│   ├── modules/              # Your custom modules
│   │   └── my_module.py
│   ├── module_utils/         # Shared Python code
│   │   └── my_utils.py
│   ├── action/               # Action plugins
│   └── filter/               # Jinja2 filters
├── roles/                    # Reusable roles
│   └── my_role/
├── playbooks/                # Example playbooks
│   └── demo.yml
└── tests/                    # Unit and integration tests
    └── test_my_module.py
Output
No output — directory structure.
Senior Shortcut:
Use module_utils for any code shared between modules. It's imported as from ansible_collections.my_namespace.my_collection.plugins.module_utils.my_utils import helper_func. This keeps your modules lean and testable.

Writing Your First Collection Module

Modules are Python scripts that return JSON. Ansible executes them on the target host. Your module must inherit from AnsibleModule and call module.exit_json() or module.fail_json(). The argument_spec defines your parameters. Always use supports_check_mode=True — your users will thank you. Here's a module that checks if a file exists and optionally creates it.

plugins/modules/file_check.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
# io.thecodeforge — DevOps tutorial

#!/usr/bin/python

from ansible.module_utils.basic import AnsibleModule
import os

def run_module():
    module_args = dict(
        path=dict(type='str', required=True),
        state=dict(type='str', default='present', choices=['present', 'absent']),
    )

    module = AnsibleModule(
        argument_spec=module_args,
        supports_check_mode=True
    )

    path = module.params['path']
    state = module.params['state']

    result = dict(
        changed=False,
        path=path,
        exists=os.path.exists(path),
    )

    if state == 'present' and not os.path.exists(path):
        if module.check_mode:
            module.exit_json(changed=True, msg="Would create file", **result)
        # In real module, you'd create the file here
        result['changed'] = True
        result['exists'] = True
    elif state == 'absent' and os.path.exists(path):
        if module.check_mode:
            module.exit_json(changed=True, msg="Would remove file", **result)
        # Remove file logic
        result['changed'] = True
        result['exists'] = False

    module.exit_json(**result)

if __name__ == '__main__':
    run_module()
Output
No output — this is a module definition.
Interview Gold:
Question: 'How does check_mode work in custom modules?' Answer: The module receives ANSIBLE_CHECK_MODE env var. You must check module.check_mode and return changed=True without making changes. If you forget, check mode is broken.

Building and Installing Your Collection

Once your module is written, build the collection tarball with ansible-galaxy collection build. This reads galaxy.yml and packages everything. Install it locally with ansible-galaxy collection install my_namespace-my_collection-1.0.0.tar.gz. For development, use collections/ansible_collections/my_namespace/my_collection/ in your playbook repo — symlink or copy. Never install from source in production; always use the tarball.

build_and_install.devopsDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
# io.thecodeforge — DevOps tutorial

# Build the collection
ansible-galaxy collection build --output-path ./dist
# Output: ./dist/my_namespace-my_collection-1.0.0.tar.gz

# Install from tarball
ansible-galaxy collection install ./dist/my_namespace-my_collection-1.0.0.tar.gz
# Installed to ~/.ansible/collections/ansible_collections/my_namespace/my_collection/

# Verify installation
ansible-galaxy collection list | grep my_namespace
# Output: my_namespace.my_collection 1.0.0
Output
my_namespace.my_collection 1.0.0
Never Do This:
Don't install collections by copying the source directory into ~/.ansible/collections/. Always use ansible-galaxy collection install with the tarball. Otherwise, you'll miss the metadata and dependency resolution.

Testing Your Collection: Don't Skip This

Testing a collection is non-negotiable. Use ansible-test from the ansible-core package. Write unit tests for your module logic using Python's unittest and mock the AnsibleModule calls. For integration tests, write playbooks that exercise your module and assert the output. Run ansible-test units and ansible-test integration. The classic mistake: testing only the happy path. Test failure modes — invalid params, missing files, permission errors.

tests/test_file_check.pyPYTHON
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
# io.thecodeforge — DevOps tutorial

from unittest import TestCase
from unittest.mock import patch, MagicMock
from ansible_collections.my_namespace.my_collection.plugins.modules.file_check import run_module

class TestFileCheckModule(TestCase):
    @patch('ansible_collections.my_namespace.my_collection.plugins.modules.file_check.AnsibleModule')
    def test_file_exists(self, mock_module):
        # Mock the module instance
        instance = mock_module.return_value
        instance.params = {'path': '/tmp/test', 'state': 'present'}
        instance.check_mode = False

        # Mock os.path.exists to return True
        with patch('os.path.exists', return_value=True):
            run_module()
            instance.exit_json.assert_called_with(
                changed=False,
                path='/tmp/test',
                exists=True
            )

    @patch('ansible_collections.my_namespace.my_collection.plugins.modules.file_check.AnsibleModule')
    def test_file_absent_check_mode(self, mock_module):
        instance = mock_module.return_value
        instance.params = {'path': '/tmp/test', 'state': 'absent'}
        instance.check_mode = True

        with patch('os.path.exists', return_value=True):
            run_module()
            instance.exit_json.assert_called_with(
                changed=True,
                msg="Would remove file",
                path='/tmp/test',
                exists=True
            )
Output
No output — test passes silently.
Senior Shortcut:
Use ansible-test with --docker to run tests in a consistent environment. This catches OS-specific bugs before they hit production.

Publishing to Ansible Galaxy

When you're ready to share, publish to Ansible Galaxy. First, create an API token on galaxy.ansible.com. Then run ansible-galaxy collection publish ./dist/my_namespace-my_collection-1.0.0.tar.gz --api-key YOUR_TOKEN. After publishing, anyone can install with ansible-galaxy collection install my_namespace.my_collection. Versioning is critical — follow semantic versioning. A breaking change? Bump the major version. A bug fix? Patch version. Never overwrite a published version — Galaxy doesn't allow it, and for good reason.

publish.devopsDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# io.thecodeforge — DevOps tutorial

# Set API key (or use --api-key)
export ANSIBLE_GALAXY_API_KEY='your_token_here'

# Publish the collection
ansible-galaxy collection publish ./dist/my_namespace-my_collection-1.0.0.tar.gz
# Output: Successfully published collection my_namespace.my_collection version 1.0.0

# Install from Galaxy
ansible-galaxy collection install my_namespace.my_collection
# Output: Process install dependency map...
# Starting collection install process...
# Installing 'my_namespace.my_collection:1.0.0'
Output
Successfully published collection my_namespace.my_collection version 1.0.0
Production Trap:
Never publish a collection with a -dev or -rc suffix to Galaxy. Use a private repository or internal Galaxy server for pre-release versions. Galaxy is for stable releases only.

Dependencies and Requirements

Your collection may depend on other collections or Python packages. Declare collection dependencies in galaxy.yml under dependencies. For Python dependencies, use requirements.txt in the collection root. When a user installs your collection, Ansible Galaxy will automatically install the dependent collections. Python dependencies are not auto-installed — you must document them and have users install them separately. The classic gotcha: forgetting to declare a dependency and getting a cryptic import error at runtime.

galaxy.ymlYAML
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
# io.thecodeforge — DevOps tutorial

# galaxy.yml
namespace: my_namespace
name: my_collection
version: 1.0.0
authors:
  - Your Name
description: Collection for file management
license:
  - MIT
dependencies:
  community.general: '>=5.0.0'
  ansible.posix: '>=1.0.0'
tags:
  - file
  - system
repository: https://github.com/yourorg/my_collection
documentation: https://github.com/yourorg/my_collection/docs
homepage: https://github.com/yourorg/my_collection
issues: https://github.com/yourorg/my_collection/issues
Output
No output — YAML manifest.
The Classic Bug:
If your module imports module_utils from another collection, you must add that collection as a dependency. Otherwise, the import fails with ModuleNotFoundError: No module named 'ansible_collections.other_namespace'.

When NOT to Use a Collection

Collections are overkill for a single module used in one playbook. If you have one-off automation, just inline the module or use a simple script. Also, avoid collections if your team can't agree on a namespace. The namespace is global — pick once, live with it. Finally, if you need to support multiple Ansible versions, collections can be tricky. Each collection targets a specific requires_ansible version. If you need wide compatibility, test across versions.

when_not_to.devopsDEVOPS
1
2
3
4
5
6
7
8
9
10
11
12
13
14
# io.thecodeforge — DevOps tutorial

# Simple alternative: use a script module
- hosts: localhost
  tasks:
    - name: Run custom script
      script: my_script.py
      args:
        executable: python3

# Or use command module with a Python one-liner
- name: Check file existence
  command: python3 -c "import os; print(os.path.exists('/tmp/test'))"
  register: result
Output
No output — structural example.
Senior Shortcut:
For a single module used in one playbook, don't create a collection. Use library/ in the playbook repo — but only if you're the sole consumer. The moment a second team needs it, migrate to a collection.
● Production incidentPOST-MORTEMseverity: high

The Version Mismatch That Took Down Deployments

Symptom
Deployments failing with module_utils import errors across 20+ servers.
Assumption
The team assumed the collection was pinned to the same version in all playbooks.
Root cause
One playbook referenced my_namespace.my_collection without a version constraint, and the CI pipeline had cached an older collection tarball. The module_utils directory had been restructured in the new version, breaking imports.
Fix
Add ansible-galaxy collection install my_namespace.my_collection:==1.2.3 to the CI pipeline. Use collections: in requirements.yml with explicit version ranges.
Key lesson
  • Always pin collection versions in your requirements file.
  • Never rely on implicit latest.
Production debug guideSystematic recovery paths for the failure modes engineers actually hit.3 entries
Symptom · 01
ERROR! couldn't resolve module/action when running playbook
Fix
1. Run ansible-galaxy collection list | grep <namespace> to check if installed. 2. If not installed, run ansible-galaxy collection install <namespace>.<name>:<version>. 3. If installed but not found, check playbook's collections keyword: collections: - <namespace>.<name>.
Symptom · 02
ModuleNotFoundError: No module named 'ansible_collections...' during module execution
Fix
1. Verify the collection is installed on the target host (if using delegate_to or local_action). 2. Check galaxy.yml dependencies — missing dependency causes this. 3. Run ansible-galaxy collection install -r requirements.yml to install all dependencies.
Symptom · 03
Collection module returns unexpected results in check mode
Fix
1. Review module code: ensure module.check_mode is checked before making changes. 2. Test with --check flag: ansible-playbook playbook.yml --check. 3. If module doesn't support check mode, add supports_check_mode=True in AnsibleModule constructor.
★ Custom Ansible Collection Triage Cheat SheetFirst-response commands for when things go wrong — copy-paste ready.
Module not found: `ERROR! couldn't resolve module/action`
Immediate action
Check if collection is installed
Commands
ansible-galaxy collection list | grep my_namespace
ansible-galaxy collection install my_namespace.my_collection:==1.0.0
Fix now
Add collections: to playbook and pin version in requirements.yml
Import error: `ModuleNotFoundError: No module named 'ansible_collections...'`+
Immediate action
Check collection dependencies
Commands
cat galaxy.yml | grep dependencies
ansible-galaxy collection install -r requirements.yml
Fix now
Add missing dependency to galaxy.yml and reinstall
Check mode not working: module makes changes despite `--check`+
Immediate action
Check module code for check_mode support
Commands
grep -r 'check_mode' plugins/modules/
ansible-playbook playbook.yml --check -v
Fix now
Add supports_check_mode=True to AnsibleModule and implement check_mode logic
Build fails: `galaxy.yml` parse error+
Immediate action
Validate YAML syntax
Commands
python -c "import yaml; yaml.safe_load(open('galaxy.yml'))"
ansible-galaxy collection build --verbose
Fix now
Fix YAML syntax errors and ensure all required fields are present
FeatureAd-hoc library/Collection
VersioningNoneSemantic versioning
Dependency managementManualAutomatic via galaxy.yml
DistributionCopy-pasteGalaxy or tarball
Namespace isolationNoneFully namespaced
Testing supportManualansible-test integration
OverheadLowMedium (structure + manifest)
⚙ Quick Reference
8 commands from this guide
FileCommand / CodePurpose
before_collections.devops- hosts: allWhy Bother? The Pain of Ad-Hoc Modules
collection_structure.devopsmy_collection/Anatomy of a Collection
pluginsmodulesfile_check.pyfrom ansible.module_utils.basic import AnsibleModuleWriting Your First Collection Module
build_and_install.devopsansible-galaxy collection build --output-path ./distBuilding and Installing Your Collection
teststest_file_check.pyfrom unittest import TestCaseTesting Your Collection
publish.devopsexport ANSIBLE_GALAXY_API_KEY='your_token_here'Publishing to Ansible Galaxy
galaxy.ymlnamespace: my_namespaceDependencies and Requirements
when_not_to.devops- hosts: localhostWhen NOT to Use a Collection

Key takeaways

1
A collection is a directory with galaxy.yml, roles/, plugins/, and tests/.
2
Write custom modules as Python files in plugins/modules/.
3
Build with ansible-galaxy collection build, publish to Galaxy.
4
Test with ansible-test before publishing. Use semantic versioning.

Common mistakes to avoid

3 patterns
×

Not including a galaxy.yml manifest

Fix
The galaxy.yml file is required for building and publishing collections. Include namespace, name, version, and dependencies.
×

Skipping testing before publishing

Fix
Run ansible-test sanity and ansible-test units before publishing. A broken collection published to Galaxy affects all users.
×

Not versioning collections properly

Fix
Use semantic versioning. Breaking changes require a major version bump. Deprecate modules in meta/runtime.yml before removing them.
INTERVIEW PREP · PRACTICE MODE

Interview Questions on This Topic

Q01SENIOR
How does Ansible resolve module names when multiple collections define t...
Q02SENIOR
When would you choose a custom collection over a role?
Q03SENIOR
What happens if you publish a collection with a missing dependency in ga...
Q04JUNIOR
What is the purpose of `module_utils` in a collection?
Q05SENIOR
You have a collection that works locally but fails in CI with 'module no...
Q06SENIOR
How would you design a collection to support multiple versions of Ansibl...
Q01 of 06SENIOR

How does Ansible resolve module names when multiple collections define the same module name?

ANSWER
Ansible uses the collections keyword in the playbook to set a search order. If not specified, it falls back to the built-in modules. If two collections define the same module, the first one in the collections list wins. Always use fully qualified collection names (FQCN) like my_namespace.my_collection.my_module to avoid ambiguity.
FAQ · 6 QUESTIONS

Frequently Asked Questions

01
What is the structure of an Ansible collection?
02
How do I create a custom Ansible module?
03
How do I publish a collection to Ansible Galaxy?
04
What is galaxy.yml and what goes in it?
05
How do I test a custom collection?
06
Can I put roles inside a collection?
N
Naren Founder & Principal Engineer

20+ years shipping production infrastructure and CI/CD at scale. Drawn from code that ran under real load.

Follow
Verified
production tested
July 11, 2026
last updated
1,727
articles · all by Naren
🔥

That's Ansible. Mark it forged?

3 min read · try the examples if you haven't

Previous
Ansible Azure Automation
35 / 37 · Ansible
Next
Configuration Drift and Compliance