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 sudo privileges.
  • 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:

PathPurpose
galaxy.ymlCollection metadata: namespace, name, version, dependencies
meta/runtime.ymlSupported 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.mdDocumentation

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_if makes Ansible reject state=present without content, so the module never has to validate that itself.
  • module.check_mode skips 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 diff lets ansible-playbook --diff show 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. Run ansible-galaxy collection list and ansible-config dump | grep COLLECTIONS_PATHS and make sure the install location is listed.
  • ansible-doc shows an error about the documentation: the DOCUMENTATION string is not valid YAML, usually an indentation problem. ansible-test sanity --test validate-modules --docker default points 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.yml defines requires_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.