Collections: Where Modules Come From

Scenario: A playbook that works on one engineer's machine fails on the CI runner with "couldn't resolve module/action 'community.general.ufw'". Both have Ansible installed.

New words, in plain English

Modules used to ship as one enormous monolith. They now live in collections, each with a namespace, its own release cycle and its own maintainers. ansible.builtin ships with the engine; everything else is installed.

This explains the scenario exactly. Installing ansible-core gives you ansible.builtin and nothing more. Installing the ansible package gives you a large curated bundle. A laptop with the bundle and a CI runner with just core will disagree about which modules exist - and the error message names a module rather than the missing collection, so the cause is not obvious.

Always write the fully qualified name: ansible.builtin.copy, not copy. Short names still resolve, but they depend on search order, and two collections can define the same short name. The FQCN is unambiguous and it documents the dependency in the code itself.

Dependencies belong in requirements.yml, installed with ansible-galaxy collection install -r requirements.yml. Pin versions. An unpinned collection is a moving dependency in your production change path, and a minor release that changes a module's default is exactly the kind of surprise you do not want to discover during a deployment.

Analogy: Collections are package repositories for automation. ansible.builtin is the standard library that comes with the language; everything else is a dependency you install, pin and review - and "it works on my machine" has the same cause it always did.

A worked example

# requirements.yml - pinned, committed, installed in CI
collections:
  - name: community.general
    version: "9.2.0"
  - name: ansible.posix
    version: "1.5.4"

roles:
  - name: geerlingguy.nginx
    version: "3.1.4"

# Install exactly what is pinned
ansible-galaxy install -r requirements.yml

# Always use the fully qualified name in tasks
- ansible.builtin.copy:      { dest: /etc/x, content: "y" }
- ansible.posix.sysctl:      { name: net.core.somaxconn, value: '4096' }
- community.general.ufw:     { rule: allow, port: '443' }

ansible-galaxy collection list shows what is actually installed and where, which is the fastest way to settle a "works on my machine" argument. ansible-doc -l lists available modules, and ansible-doc ansible.builtin.copy prints the full option reference for one - offline, with examples, and matching the version you actually have.

Collections can be sourced from Galaxy, from a private Automation Hub, from a Git repository or from a local tarball. For anything regulated or air-gapped, a private mirror plus pinned versions is the normal arrangement, and it also removes a public-registry outage from your deployment critical path.

Warning: Never leave a collection or role unpinned in requirements.yml. An upstream release can change module behaviour between two runs of the same playbook, and the diff will be nowhere in your repository.
Goal: Put this to work in the ansible-role-refactor lab. Open /labs/ansible, pick ansible-role-refactor, and fix the real broken project - Ansible really does SSH into four managed hosts and converge them.