AnsibleIII · Installing and Designing the ControllerInstalling the controller
A controller you can rebuild identically
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
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 --versionandansible-galaxy collection listmust 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.
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.401
collections[].name= name: community.generalThe 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.
02
collections[].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.
03
collections[].source= source: https://galaxy.ansible.comWhich 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.
04
roles= roles: - name: geerlingguy.postgresql version: 3.5.4Standalone 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.
#!/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 listThe 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.
$ ansible --versionansible [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)$ ansible-galaxy collection list# /home/ops/estate/collections/ansible_collections
Collection Version
----------------- -------
ansible.posix 2.1.0
community.general 11.4.0Two 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:
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:
/opt/estate/venv/bin/pip freeze$ pip freezeansible-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.1Collection 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
Q1. A repository pins every collection in requirements.yml but has no requirements.txt. What is unpinned?
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.
Q3. Pinning ansible-core in requirements.txt makes the controller byte-for-byte reproducible.
Q4. Why is `ansible-galaxy collection list` the manifest rather than requirements.yml?
Passing score: 75%. Answers are checked in this browser.