AnsibleVI · Configuration and PrecedenceConfiguration and precedence
The project config is part of the automation
What you'll learn
- Write a complete, portable project ansible.cfg and commit it with the automation
- Explain why a personal ~/.ansible.cfg is a divergence rather than a convenience
- Express per-environment configuration in CI as explicit environment variables
- Record the effective configuration in every run log so a result is reproducible
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
The playbooks are in Git. The roles are in Git. The inventory is in Git.
And then the run behaves differently on the CI runner than on your laptop,
because forks, timeout, the interpreter, the callback plugins and
whether host keys are verified were never captured anywhere.
The configuration is part of the automation. It belongs in the same commit, reviewed by the same people, for the same reason.
A project ansible.cfg
It lives at the repository root, beside the playbooks, and it is committed.
estate/
ansible.cfg <- committed, complete, short
site.yml
inventory/
roles/
collections/
Two properties from earlier lessons make this work, and both are worth restating because they are what makes the file portable.
Relative paths resolve against the config file’s directory, not the
working directory. So inventory = ./inventory/production is correct in
every checkout, on every machine, in CI, without templating an absolute
path.
The file must be complete. Only one config file is ever loaded, so
anything not in this file falls back to a built-in default and not to
whatever /etc/ansible/ansible.cfg says. Every setting the estate depends
on has to be here.
An annotated project configuration
This file was validated on ansible-core 2.21.3 with
ansible-config validate -t all, and every setting shown appears in
ansible-config dump --only-changed -t all afterwards.
[defaults] inventory: ./inventory/production
[defaults] roles_path: ./roles
[defaults] collections_path: ./collections
[defaults] forks: 20
[defaults] timeout: 15
[defaults] host_key_checking: True
[defaults] interpreter_python: auto
[defaults] deprecation_warnings: True
[defaults] retry_files_enabled: False
[defaults] stdout_callback: default
[defaults] gathering: smart
[privilege_escalation] become: False
[privilege_escalation] become_method: sudo
[ssh_connection] pipelining: True
[ssh_connection] control_path_dir: ~/.ansible/cp01
[defaults] inventory= ./inventory/productionDefault inventory source. The path is relative to this file, so it is correct in every checkout without templating.
Production: Point it at the safest environment, or leave it unset. A default that includes production turns a forgotten -i into a fleet-wide run.
⚠ Setting it to ./inventory - the parent of every environment directory - merges all environments into one inventory.
02
[defaults] roles_path= ./rolesWhere roles are searched for. Overriding the default keeps role resolution inside the repository instead of reaching into ~/.ansible/roles.
Production: A repository-local roles_path means two operators with different personal role collections still run the same code.
03
[defaults] collections_path= ./collectionsWhere installed collections are searched for. Pair it with a requirements.yml and ansible-galaxy collection install -p ./collections.
Production: Repository-local collections are what makes a pinned dependency set actually pinned, rather than pinned on the machine that ran the install.
04
[defaults] forks= 20Maximum parallel worker processes on the controller. The built-in default is 5.
Production: Size it to controller memory and CPU, and put the deliberate blast-radius limit in the play as serial rather than relying on this number.
⚠ Raising forks without adding serial removes an accidental safety limit and replaces it with nothing.
05
[defaults] timeout= 15Connection timeout in seconds. The built-in default is 10.
Production: Prefer ansible_ssh_timeout on the specific group that needs patience over a global increase, so healthy hosts keep failing fast.
06
[defaults] host_key_checking= TrueVerify the managed node host key. This is the built-in default, restated deliberately.
Production: Stating a security default explicitly turns a silent assumption into a reviewable line. Removing it later is then a visible diff.
07
[defaults] interpreter_python= autoDiscovery mode for the remote Python interpreter. Left at auto so the discovery warning still appears.
Production: Pin ansible_python_interpreter per host group in inventory. Do not set auto_silent here - it hides the warning for the whole estate.
08
[defaults] deprecation_warnings= TrueShow deprecation warnings. The built-in default, restated so that disabling it is a visible change.
Production: These are your only advance notice that an ansible-core upgrade will break something.
09
[defaults] retry_files_enabled= FalseDo not write .retry files. This is already the default on 2.21.3; older material assumes otherwise.
⚠ Enabling it and then rerunning with --limit @site.retry targets a host list that was accurate when the previous run ended, not now.
10
[defaults] stdout_callback= defaultWhich callback renders run output. Stated explicitly so that a personal preference on one machine cannot change what a shared log looks like.
11
[defaults] gathering= smartFact-gathering policy. smart gathers facts once per host per run unless already cached.
Production: Meaningful only alongside a fact cache; the performance part of this course covers the trade-offs.
12
[privilege_escalation] become= FalseDo not escalate by default. Plays and tasks that need root ask for it explicitly.
Production: A global become = True makes every task run as root, including the ones that had no reason to. Escalate at the task, where it is reviewable.
13
[privilege_escalation] become_method= sudoThe escalation mechanism. Stated so that a host family needing something else has to say so in inventory rather than inheriting a surprise.
14
[ssh_connection] pipelining= TrueExecute modules without a separate file transfer, which removes several SSH operations per task.
Production: Requires requiretty to be disabled in sudoers on the managed nodes, which is why it is off by default. Verify that before enabling it fleet-wide.
15
[ssh_connection] control_path_dir= ~/.ansible/cpDirectory for SSH control sockets. Stated explicitly because a path that is too long for a UNIX socket causes connection failures that read as network faults.
Notice what is not in it: no ssh_args, because overriding it
replaces the default connection multiplexing rather than adding to it; no
vault_password_file, because that is a per-machine path; and nothing
that would be a secret.
The personal ~/.ansible.cfg is a divergence
Once the project config is committed, a personal one has no upside and one specific danger, which follows directly from the search order.
~/.ansible.cfg is only consulted when no ./ansible.cfg is found — and
because location 2 is the current working directory with no upward search,
that happens by accident, whenever somebody runs from a subdirectory
of the repository.
So the personal config does nothing at all until the moment somebody types
cd playbooks first, and then it silently supplies a different forks, a
different timeout, possibly a different inventory, and possibly a
different answer on host key checking.
ls -l ~/.ansible.cfg 2>/dev/null && ansible-config dump --only-changed -t allCI should say what it configures, in the pipeline
The temptation in CI is to commit a second config file, or to bake settings into the runner image. Both hide the configuration somewhere the run log cannot show you.
Express it as environment variables in the pipeline definition, which makes it reviewable in the same repository and visible in the job:
# .gitlab-ci.yml or equivalent
variables:
ANSIBLE_CONFIG: '$CI_PROJECT_DIR/ansible.cfg'
ANSIBLE_FORCE_COLOR: 'true'
ANSIBLE_STDOUT_CALLBACK: 'default'
ANSIBLE_FORKS: '30'
ANSIBLE_CONFIG is doing the most important work there. It is location 1
in the search order, so it applies regardless of which directory the job
happens to run from — and CI runners change working directory more often
than people expect. It also survives the world-writable-directory case, on
runners whose build directories are created permissively.
Knowledge check
Knowledge check · 4 questions
Q1. Why is a personal ~/.ansible.cfg specifically dangerous on a controller that runs a repository with its own committed ansible.cfg?
Q2. A base ansible.cfg plus a per-environment overlay file is a workable way to vary a few settings per environment.
Q3. Which belong in a committed project ansible.cfg? Select all that apply.
Q4. Why does a CI pipeline benefit from setting ANSIBLE_CONFIG explicitly rather than relying on ./ansible.cfg being found?
Passing score: 75%. Answers are checked in this browser.