Skip to main content
RunBook Academy

AnsibleIII · Installing and Designing the ControllerInstalling the controller

A controller you can rebuild identically

Intermediate⏱ ~22 minbashpython3git

What you'll learn

  • Pin ansible-core in requirements.txt and collections in requirements.yml, and explain why two files are needed
  • Build a controller environment reproducibly from files in the repository
  • Run the two-machine test: identical ansible --version and identical collection list
  • Identify what remains unpinned after both files exist, and decide what to do about it

Prerequisites

Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11

Not yet marked complete on this device.

Here is the test this part of the course is built around, and it is a short one.

Take the repository to a second machine. Build a controller from what is in it. ansible --version and ansible-galaxy collection list must produce the same answers as the first machine.

If they do, you have a controller environment. If they do not, you have a machine that happens to work, and the difference will surface at the least convenient moment — usually as a CI job that fails on code a human just tested successfully.

Two files, because there are two mechanisms

Pinning Ansible needs two files, and forgetting one is the commonest reproducibility gap in an otherwise disciplined repository.

estate/
├── requirements.txt      # ansible-core, via pip
├── requirements.yml      # collections, via ansible-galaxy
├── ansible.cfg
├── inventory/
└── playbooks/

requirements.txt pins the runtime and ansible.builtin — because ansible.builtin is not a separately installed collection, its version is the ansible-core version:

# requirements.txt
ansible-core==2.21.3

requirements.yml pins everything else:

# requirements.yml
---
collections:
  - name: ansible.posix
    version: '2.1.0'
  - name: community.general
    version: '11.4.0'

Reading requirements.yml properly

The collection entry has more in it than a name and a version, and the options change what “pinned” means.

requirements.ymlA collection requirements file with the options that decide how strong the pin actually is.
collections[].name: name: community.general
collections[].version: version: '11.4.0'
collections[].source: source: https://galaxy.ansible.com
roles: roles:
  - name: geerlingguy.postgresql
    version: 3.5.4
  1. 01collections[].name= name: community.general

    The fully qualified collection name, namespace and collection. This is what a playbook writes as `community.general.<module>`.

    Production: Every module your playbooks call belongs to some collection. Run `ansible-doc <module>` to find which one before assuming it is ansible.builtin.

    ⚠ Listing a role name here. Roles go under a separate `roles:` key in the same file; collections and roles are different install mechanisms.

  2. 02collections[].version= version: '11.4.0'

    An exact version. ansible-galaxy installs precisely this and nothing else.

    Production: Quote it. Unquoted, a version like 2.10 is parsed by YAML as the float 2.1, and you will install a version you did not ask for.

    ⚠ Writing `version: '>=11.0.0'`. That is a valid range and a legitimate choice, but it is not a pin - two machines built a month apart will get different code.

  3. 03collections[].source= source: https://galaxy.ansible.com

    Which Galaxy server to fetch from. Defaults to the public Galaxy unless GALAXY_SERVERS is configured.

    Production: An estate with an internal Automation Hub or a proxy sets this, or configures servers in ansible.cfg so the file stays environment-neutral.

    ⚠ Hardcoding an internal URL in a repository that also builds on a laptop with no VPN. The build then fails in a way that looks like a network fault.

  4. 04roles= roles: - name: geerlingguy.postgresql version: 3.5.4

    Standalone roles from Galaxy, a separate install mechanism that predates collections and still works.

    Production: Pin these exactly as you pin collections. A standalone role is code that runs against your fleet with no version guard unless you write one.

    ⚠ Adding `-p ./roles` to `ansible-galaxy install -r requirements.yml`. That flag is `--roles-path`, and supplying it narrows the command to roles only, silently skipping the collections section of the same file.

Building the environment

Five commands, and they belong in a script in the repository rather than in anyone’s shell history.

Configuration changebin/build-controller - the whole thing
#!/usr/bin/env bash
set -euo pipefail

VENV=${VENV:-/opt/estate/venv}
REPO=$(cd "$(dirname "$0")/.." && pwd)

python3.12 -m venv "$VENV"
"$VENV/bin/pip" install --upgrade pip
"$VENV/bin/pip" install -r "$REPO/requirements.txt"
"$VENV/bin/ansible-galaxy" collection install \
  -r "$REPO/requirements.yml" -p "$REPO/collections"

"$VENV/bin/ansible" --version
"$VENV/bin/ansible-galaxy" collection list

The install of collections is genuinely a CONFIGURATION action, not a read-only one. It does not touch a managed host, but it changes what the next playbook run will execute on every managed host — which is a change with a fleet-wide blast radius, made on the controller.

Point Ansible at the repository-local collections directory so the result is a property of the repository rather than of the user’s home directory:

# ansible.cfg
[defaults]
collections_path = ./collections

Verifying the build

The manifest is two commands, and comparing them between machines is the test.

Read-only / Safemanifest, part one: the runtime
$ ansible --version
ansible [core 2.21.3]
config file = /home/ops/estate/ansible.cfg
configured module search path = ['/home/ops/.ansible/plugins/modules', '/usr/share/ansible/plugins/modules']
ansible python module location = /opt/estate/venv/lib/python3.14/site-packages/ansible
ansible collection location = /home/ops/estate/collections
executable location = /opt/estate/venv/bin/ansible
python version = 3.14.4 (main, Jun 18 2026, 14:25:02) [GCC 15.2.0] (/opt/estate/venv/bin/python3)
jinja version = 3.1.6
pyyaml version = 6.0.3 (with libyaml v0.2.5)
Read-only / Safemanifest, part two: the collections
$ ansible-galaxy collection list
# /home/ops/estate/collections/ansible_collections
Collection        Version
----------------- -------
ansible.posix     2.1.0
community.general 11.4.0

Two collections, both at the versions in the file. On a machine built from the distribution’s community package, the same command lists ninety-one collections at versions nobody chose. That is the difference the pin buys, and it is visible in one command.

Make the comparison mechanical rather than visual:

Read-only / Safethe two-machine test as a diff
ansible --version | grep -E 'core|python version|jinja' > /tmp/manifest-a.txt
ansible-galaxy collection list >> /tmp/manifest-a.txt

# on the second controller, produce /tmp/manifest-b.txt the same way, then:
diff /tmp/manifest-a.txt /tmp/manifest-b.txt && echo "controllers agree"

What is still not pinned

Two files is a large improvement and it is not complete. Four things remain loose, in rough order of how often they cause trouble.

The controller’s Python. requirements.txt pins ansible-core; it does not pin the interpreter the venv was built from. python3.12 -m venv on one machine and python3.14 -m venv on another produce two different controllers from the same file. Name the interpreter in the build script, and record it — a comment in requirements.txt is enough for a small estate, a base-image tag for a larger one.

Transitive Python dependencies. ansible-core==2.21.3 pins Ansible and lets pip resolve jinja2, cryptography, pyyaml, packaging and resolvelib to whatever is newest at build time. Two builds a month apart get different Jinja. For a controller that must be byte-reproducible, generate a fully pinned file:

Read-only / Safecapture the whole resolved tree
/opt/estate/venv/bin/pip freeze
Read-only / Safewhat a pinned controller's dependency tree actually is
$ pip freeze
ansible-core==2.21.3
cffi==2.1.1
cryptography==50.0.0
Jinja2==3.1.6
MarkupSafe==3.0.3
packaging==26.3
pycparser==3.0
PyYAML==6.0.3
resolvelib==1.2.1

Collection dependencies. A collection may depend on another collection, and ansible-galaxy will install that dependency at whatever version satisfies the constraint. ansible-galaxy collection list shows you what actually landed — which is why the manifest is the list, not the requirements file.

Roles installed outside the file. Anything a person installed by hand on the controller is invisible to the repository and will not exist on machine two. The two-machine test catches this, which is most of why it is worth running.

Knowledge check

Knowledge check · 4 questions

  1. Q1. A repository pins every collection in requirements.yml but has no requirements.txt. What is unpinned?

  2. Q2. A build reports success, yet the next playbook run fails with a module-not-found error naming a collection. Which explanations fit? Select all that apply.

  3. Q3. Pinning ansible-core in requirements.txt makes the controller byte-for-byte reproducible.

  4. Q4. Why is `ansible-galaxy collection list` the manifest rather than requirements.yml?

Passing score: 75%. Answers are checked in this browser.