Skip to main content
RunBook Academy

AnsibleVI · Configuration and PrecedenceConfiguration and precedence

The project config is part of the automation

Advanced⏱ ~18 minansible-coregit

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

Not yet marked complete on this device.

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.

ansible.cfgA complete project configuration. Every line is a decision, not a default restated.
[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/cp
  1. 01[defaults] inventory= ./inventory/production

    Default 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.

  2. 02[defaults] roles_path= ./roles

    Where 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.

  3. 03[defaults] collections_path= ./collections

    Where 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.

  4. 04[defaults] forks= 20

    Maximum 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.

  5. 05[defaults] timeout= 15

    Connection 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.

  6. 06[defaults] host_key_checking= True

    Verify 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.

  7. 07[defaults] interpreter_python= auto

    Discovery 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.

  8. 08[defaults] deprecation_warnings= True

    Show 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.

  9. 09[defaults] retry_files_enabled= False

    Do 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. 10[defaults] stdout_callback= default

    Which callback renders run output. Stated explicitly so that a personal preference on one machine cannot change what a shared log looks like.

  11. 11[defaults] gathering= smart

    Fact-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. 12[privilege_escalation] become= False

    Do 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. 13[privilege_escalation] become_method= sudo

    The escalation mechanism. Stated so that a host family needing something else has to say so in inventory rather than inheriting a surprise.

  14. 14[ssh_connection] pipelining= True

    Execute 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. 15[ssh_connection] control_path_dir= ~/.ansible/cp

    Directory 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.

Read-only / Safecheck whether you have one
ls -l ~/.ansible.cfg 2>/dev/null && ansible-config dump --only-changed -t all

CI 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

  1. Q1. Why is a personal ~/.ansible.cfg specifically dangerous on a controller that runs a repository with its own committed ansible.cfg?

  2. Q2. A base ansible.cfg plus a per-environment overlay file is a workable way to vary a few settings per environment.

  3. Q3. Which belong in a committed project ansible.cfg? Select all that apply.

  4. 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.