An Ansible collection is the standard way to package and version Ansible content: modules, plugins, roles and playbooks live under one namespace.name and are installed with ansible-galaxy. In this tutorial you will create a collection called mycompany.infrastructure on Ubuntu 24.04, add an idempotent custom module, a Jinja2 filter plugin and a role, test everything against localhost, and then build, install and publish the collection.
Prerequisites
To follow this tutorial you need:
- A machine running Ubuntu 24.04 LTS, for example a CubePath VPS, with a non-root user that has
sudoprivileges. - Basic familiarity with Ansible playbooks and YAML.
- Optional, for publishing: an account on Ansible Galaxy (you sign in with GitHub) or a private Automation Hub.
- Optional, for sanity tests: Docker installed on the machine.
Step 1 - Installing Ansible and the development tools
Ubuntu 24.04 marks the system Python as externally managed, so install ansible-core and ansible-lint with pipx, which gives each tool its own virtual environment:
sudo apt update
sudo apt install pipx git
pipx ensurepath
Open a new shell (or run source ~/.bashrc) so ~/.local/bin is in your PATH, then install the tools:
pipx install ansible-core
pipx install ansible-lint
Verify the installation:
ansible --version
ansible-galaxy collection list
The first command prints the ansible-core version and the Python it uses. The second lists collections that are already installed, which may be none on a fresh system.
Step 2 - Creating the collection skeleton
Ansible tools such as ansible-test expect a collection to live at ansible_collections/<namespace>/<name>. Create that directory layout and let ansible-galaxy generate the skeleton:
mkdir -p ~/src/ansible_collections
cd ~/src/ansible_collections
ansible-galaxy collection init mycompany.infrastructure
cd mycompany/infrastructure
- Collection mycompany.infrastructure was created successfully
The generated tree contains the pieces you will use in this tutorial:
| Path | Purpose |
|---|---|
galaxy.yml | Collection metadata: namespace, name, version, dependencies |
meta/runtime.yml | Supported Ansible versions and plugin routing |
plugins/ | Modules (plugins/modules), filters (plugins/filter), module_utils and other plugin types |
roles/ | Roles shipped with the collection |
docs/, README.md | Documentation |
Open galaxy.yml and set the metadata. The generated file is heavily commented; the relevant keys are:
nano galaxy.yml
namespace: mycompany
name: infrastructure
version: 1.0.0
readme: README.md
authors:
- Your Name <[email protected]>
description: Baseline server configuration for MyCompany hosts
license:
- Apache-2.0
tags:
- infrastructure
- linux
dependencies: {}
repository: https://github.com/your_user/ansible-collection-infrastructure
build_ignore:
- "*.tar.gz"
- tests/output
Replace your_user and the author with your own values. Galaxy requires meta/runtime.yml to declare the minimum Ansible version. Set it:
nano meta/runtime.yml
---
requires_ansible: ">=2.16.0"
Step 3 - Writing a custom module
A module is a Python file in plugins/modules/ with three documentation strings (DOCUMENTATION, EXAMPLES, RETURN) and a main() that uses AnsibleModule. A good module is idempotent (it only reports changed when it changed something) and supports check mode.
This module manages the contents of a message-of-the-day style file. Create it:
nano plugins/modules/motd.py
#!/usr/bin/python
# -*- coding: utf-8 -*-
# Apache-2.0 (see LICENSE)
from __future__ import annotations
DOCUMENTATION = r"""
---
module: motd
short_description: Manage the message of the day file
version_added: "1.0.0"
description:
- Writes a message to a MOTD file, or removes the file.
- Only reports a change when the file content differs.
options:
content:
description:
- Text to write to the file. Required when O(state=present).
type: str
path:
description:
- Path of the file to manage.
type: path
default: /etc/motd
state:
description:
- Whether the file should exist.
type: str
choices: [absent, present]
default: present
author:
- Your Name (@your_user)
"""
EXAMPLES = r"""
- name: Set a login banner
mycompany.infrastructure.motd:
content: "Managed by Ansible. Unauthorized access is prohibited."
- name: Remove the banner
mycompany.infrastructure.motd:
state: absent
"""
RETURN = r"""
path:
description: Path of the managed file.
returned: always
type: str
sample: /etc/motd
"""
import os
from ansible.module_utils.basic import AnsibleModule
def read_file(path):
if not os.path.exists(path):
return None
with open(path, encoding="utf-8") as f:
return f.read()
def main():
module = AnsibleModule(
argument_spec=dict(
content=dict(type="str"),
path=dict(type="path", default="/etc/motd"),
state=dict(type="str", default="present", choices=["absent", "present"]),
),
required_if=[("state", "present", ["content"])],
supports_check_mode=True,
)
path = module.params["path"]
state = module.params["state"]
current = read_file(path)
result = dict(changed=False, path=path)
if state == "present":
desired = module.params["content"].rstrip("\n") + "\n"
if current != desired:
result["changed"] = True
result["diff"] = dict(before=current or "", after=desired)
if not module.check_mode:
module.atomic_move(write_tmp(module, desired), path)
elif current is not None:
result["changed"] = True
result["diff"] = dict(before=current, after="")
if not module.check_mode:
os.remove(path)
module.exit_json(**result)
def write_tmp(module, content):
tmp_path = os.path.join(module.tmpdir, "motd.tmp")
with open(tmp_path, "w", encoding="utf-8") as f:
f.write(content)
return tmp_path
if __name__ == "__main__":
main()
A few details make this module well behaved:
required_ifmakes Ansible rejectstate=presentwithoutcontent, so the module never has to validate that itself.module.check_modeskips the write while still reporting whether a change would happen.module.atomic_move()replaces the file in one operation, so a reader never sees a half-written file.- Returning
diffletsansible-playbook --diffshow the change.
Step 4 - Adding a filter plugin
Filter plugins add Jinja2 filters that you call with | in templates and variables. Each filter file defines a FilterModule class whose filters() method maps names to functions.
mkdir -p plugins/filter
nano plugins/filter/naming.py
# -*- coding: utf-8 -*-
# Apache-2.0 (see LICENSE)
from __future__ import annotations
import re
DOCUMENTATION = r"""
name: to_slug
short_description: Convert a string to a lowercase, hyphen-separated slug
version_added: "1.0.0"
description:
- Lowercases the input and replaces every run of non-alphanumeric characters with a hyphen.
options:
_input:
description: The string to convert.
type: str
required: true
"""
EXAMPLES = r"""
hostname: "{{ 'Web Server 01' | mycompany.infrastructure.to_slug }}"
# => web-server-01
"""
RETURN = r"""
_value:
description: The slug.
type: str
"""
def to_slug(value):
return re.sub(r"[^a-z0-9]+", "-", str(value).lower()).strip("-")
class FilterModule:
def filters(self):
return {"to_slug": to_slug}
Step 5 - Adding a role that uses the collection
Roles inside a collection are referenced by their fully qualified collection name (FQCN), for example mycompany.infrastructure.baseline. Create a minimal role that uses the module and the filter:
mkdir -p roles/baseline/tasks roles/baseline/defaults
nano roles/baseline/defaults/main.yml
---
baseline_motd: "This server is managed by Ansible."
baseline_motd_path: /etc/motd
nano roles/baseline/tasks/main.yml
---
- name: Configure the login banner
mycompany.infrastructure.motd:
content: "{{ baseline_motd }}"
path: "{{ baseline_motd_path }}"
- name: Show the slug of this host's name
ansible.builtin.debug:
msg: "{{ inventory_hostname | mycompany.infrastructure.to_slug }}"
Always use FQCNs (ansible.builtin.debug, not debug) inside collections; ansible-lint flags short names.
Step 6 - Building and installing the collection locally
Build the collection into a versioned tarball:
ansible-galaxy collection build
Created collection for mycompany.infrastructure at /home/your_user/src/ansible_collections/mycompany/infrastructure/mycompany-infrastructure-1.0.0.tar.gz
Install it into your user collection path (~/.ansible/collections):
ansible-galaxy collection install mycompany-infrastructure-1.0.0.tar.gz --force
--force overwrites a previously installed copy with the same version, which is what you want while iterating. Confirm that Ansible can find the collection and render the module documentation:
ansible-galaxy collection list mycompany.infrastructure
ansible-doc mycompany.infrastructure.motd
If ansible-doc prints the module options, the DOCUMENTATION block is valid YAML and the module is discoverable.
Step 7 - Testing the collection
Running a playbook against localhost
Create a test playbook outside the collection directory:
mkdir -p ~/collection-test && cd ~/collection-test
nano site.yml
---
- name: Test mycompany.infrastructure
hosts: localhost
connection: local
gather_facts: false
vars:
baseline_motd_path: /tmp/motd-test
roles:
- mycompany.infrastructure.baseline
Using /tmp/motd-test keeps the test from touching /etc/motd, so no sudo is needed. Run it in check mode first, then for real, and then a second time:
ansible-playbook site.yml --check --diff
ansible-playbook site.yml
ansible-playbook site.yml
The first real run reports changed=1 for the banner task. The second run must report no changes, which proves the module is idempotent:
PLAY RECAP *********************************************************************
localhost : ok=2 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
cat /tmp/motd-test
This server is managed by Ansible.
Linting and sanity tests
Run ansible-lint from the collection root to catch style and FQCN issues:
cd ~/src/ansible_collections/mycompany/infrastructure
ansible-lint
ansible-test sanity runs the same checks Galaxy and the Ansible community use (documentation schema, Python compatibility, import tests). It must run from inside the ansible_collections/<namespace>/<name> path, and the easiest way to get all its dependencies is its default container, which requires Docker:
git init
git add .
ansible-test sanity --docker default
ansible-test only checks files tracked by Git, which is why the files are added first. Fix any reported errors and rerun until it passes.
Step 8 - Publishing the collection
To Ansible Galaxy
Your Galaxy namespace must match namespace in galaxy.yml. Namespaces are created from your GitHub user or organization name, so change mycompany to a namespace you own before publishing.
Get an API token at https://galaxy.ansible.com/ui/token/, export it and publish the tarball:
export GALAXY_TOKEN=your_galaxy_token
ansible-galaxy collection publish mycompany-infrastructure-1.0.0.tar.gz --api-key="$GALAXY_TOKEN"
Every publish needs a new version in galaxy.yml; Galaxy rejects re-uploads of an existing version. Follow semantic versioning: bump the patch for fixes, the minor for new modules or options, and the major for breaking changes.
To a private Automation Hub
For a private server, define it in ansible.cfg so both publish and install use it. In your project directory:
nano ansible.cfg
[galaxy]
server_list = private_hub, release_galaxy
[galaxy_server.private_hub]
url = https://hub.example.com/api/galaxy/
token = your_hub_token
[galaxy_server.release_galaxy]
url = https://galaxy.ansible.com/
Servers are tried in the order of server_list. Replace the URL and your_hub_token with your own values, and keep this file out of version control if it contains a token.
Step 9 - Consuming the collection with requirements.yml
Projects that use the collection declare it, with a version range, in requirements.yml:
---
collections:
- name: mycompany.infrastructure
version: ">=1.0.0,<2.0.0"
- name: community.general
version: ">=9.0.0"
- name: https://github.com/your_user/ansible-collection-infrastructure.git
type: git
version: main
The Git entry is useful before a collection is published; the repository root must contain galaxy.yml. Install everything into a project-local directory so each project pins its own versions:
ansible-galaxy collection install -r requirements.yml -p ./collections
Tell Ansible to look there first:
[defaults]
collections_path = ./collections:~/.ansible/collections:/usr/share/ansible/collections
For air-gapped hosts, download the tarballs and their dependencies on a connected machine, copy the directory over and install from it:
ansible-galaxy collection download -r requirements.yml -p ./collections-offline
The download directory contains the tarballs plus a generated requirements.yml; on the target run ansible-galaxy collection install -r requirements.yml from inside it.
Troubleshooting
couldn't resolve module/action 'mycompany.infrastructure.motd': the collection is not in any configured path. Runansible-galaxy collection listandansible-config dump | grep COLLECTIONS_PATHSand make sure the install location is listed.ansible-docshows an error about the documentation: theDOCUMENTATIONstring is not valid YAML, usually an indentation problem.ansible-test sanity --test validate-modules --docker defaultpoints to the exact field.- Old code still runs after a change: you edited the source but Ansible loads the installed copy. Rebuild and reinstall with
--force, or bump the version. - Galaxy rejects the upload: check that the namespace exists and belongs to you, that the version is new, and that
meta/runtime.ymldefinesrequires_ansible.
Conclusion
You created an Ansible collection with an idempotent module, a filter plugin and a role, verified it in check mode and with a second idempotency run, and built a versioned tarball that other projects can install from Galaxy, a private hub or Git. Next, consider adding integration tests under tests/integration/targets/ for ansible-test integration, a changelogs/ directory managed with antsibull-changelog, and a CI job that builds and publishes the collection when you push a version tag.
