Structure a large Ansible codebase: role interfaces and variable precedence, pinned collections, cloud inventory plugins, Vault and its alternatives, rolling updates with serial and max_fail_percentage, ansible-lint, and Molecule tests.
Ansible stays simple for ten hosts and stops being simple around a few hundred, when inventories change hourly, several teams share roles, and one bad variable can restart a whole fleet. This guide covers the structure and habits that keep a large Ansible codebase predictable: a sane project layout, roles with clear interfaces, pinned collections, inventory generated from the cloud API, secrets that never reach a log, rolling updates that stop themselves, and tests that run before anything touches production.
A layout should answer three questions without reading any YAML: what environments exist, what playbooks can be run, and where a given behaviour is implemented. Keep one inventory directory per environment so that a wrong -i flag is obvious, and keep playbooks thin. Logic belongs in roles.
myproject/
ansible.cfg
requirements.txt
collections/
requirements.yml
inventories/
staging/
production/
aws_ec2.yml
static.yml
group_vars/
all/
vars.yml
vault.yml
role_web.yml
playbooks/
site.yml
rolling_deploy.yml
roles/
myapp/
defaults/main.yml
handlers/main.yml
meta/main.yml
meta/argument_specs.yml
molecule/default/
tasks/main.yml
tasks/migrate.yml
templates/
vars/debian.yml
vars/redhat.yml
Pin the control node as well as the code. A playbook is only reproducible if the same ansible-core and the same collection versions run it, so install into a virtual environment from a committed file. Current ansible-core releases require a recent Python on the control node (2.22 needs 3.13 or newer), and managed nodes have their own minimum, so check the support matrix before an upgrade rather than after.
# requirements.txt
ansible-core>=2.22,<2.23
ansible-lint>=26.11,<27.0
molecule>=26.11,<27.0
molecule-plugins[docker]>=26.11,<27.0
The ansible-core constraint is narrower than the others because its minor releases can change behaviour. Version 2.19 introduced a new templating engine with stricter rules: a conditional must now produce a boolean, so when: myvar on a string fails where it once passed. Treat each minor upgrade as a project with a test run. Add boto3 for the AWS collection and hvac for HashiCorp-style vaults, pinned the same way.
Commit a project-level configuration so that every engineer and CI job behaves the same way:
# ansible.cfg
[defaults]
inventory = inventories/staging
roles_path = roles
collections_path = collections
forks = 30
gathering = smart
fact_caching = ansible.builtin.jsonfile
fact_caching_connection = .cache/facts
fact_caching_timeout = 3600
callback_result_format = yaml
callbacks_enabled = ansible.posix.profile_tasks
interpreter_python = auto_silent
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
Defaulting inventory to staging is deliberate: production then requires a typed -i inventories/production. Ansible ignores an ansible.cfg in a world-writable directory. Run ansible-config dump --only-changed to see what is actually in effect.
Treat a role as a function. Its public interface is defaults/main.yml and meta/argument_specs.yml. Everything else is implementation. Prefix every variable with the role name, because Ansible has one flat variable namespace per host and port in one role will silently collide with port in another. The var-naming[no-role-prefix] rule in ansible-lint enforces this.
# roles/myapp/defaults/main.yml
---
myapp_user: myapp
myapp_root: /srv/app
myapp_port: 8080
myapp_log_level: info
myapp_exec_start: "{{ myapp_root }}/current/bin/myapp serve --port {{ myapp_port }}"
# roles/myapp/vars/debian.yml
---
myapp_packages:
- ca-certificates
- libpq5
# roles/myapp/meta/argument_specs.yml
---
argument_specs:
main:
short_description: Install and run the myapp service
options:
myapp_db_password:
type: str
required: true
no_log: true
description: Database password written to the environment file.
myapp_port:
type: int
description: TCP port the service listens on.
myapp_log_level:
type: str
choices: [debug, info, warning, error]
description: Application log level.
Argument specs run as an implicit first task and fail with a readable message, which replaces the hand-written assert blocks that used to open most roles. They validate; they do not set values. Keep actual defaults in defaults/main.yml.
The rule for placing a variable is about who may change it. Anything a consumer may tune goes in defaults/, the lowest-priority location, so any inventory value wins. Anything that is a fact about the platform, such as package names per OS family, goes in vars/, where inventory cannot accidentally override it.
There are over twenty precedence levels. These are the ones that cause incidents, from lowest to highest:
| Source | Typical use | Common failure |
|---|---|---|
Role defaults/ | Public, tunable values | A default that is safe in staging but not production |
group_vars/all | Organisation-wide settings | Overridden by a more specific group without anyone noticing |
group_vars/<group> | Environment and tier settings | Sibling groups collide: same depth merges alphabetically, last wins |
Play vars, vars_files | Playbook-scoped values | Beats inventory, so inventory edits appear to do nothing |
Role vars/ | Internal constants | Consumer cannot override, by design; a surprise if used for tunables |
set_fact, register | Runtime values | Persist for the rest of the play on that host |
Extra vars (-e) | Emergency overrides, CI inputs | Always win, including over set_fact |
Two more details. ansible_group_priority changes merge order between sibling groups but only works in inventory sources, not in group_vars files. And leave hash_behaviour at its default of replace: global merging makes results depend on load order. If you need a merge, do it explicitly with the combine filter at the point of use. ansible-inventory --host shows inventory-level values only; use ansible.builtin.debug inside the play for values set later.
# roles/myapp/tasks/main.yml
---
- name: Load OS-specific variables
ansible.builtin.include_vars: "{{ ansible_facts['os_family'] | lower }}.yml"
- name: Install runtime packages
ansible.builtin.package:
name: "{{ myapp_packages }}"
state: present
- name: Create service group
ansible.builtin.group:
name: "{{ myapp_user }}"
system: true
- name: Create service account
ansible.builtin.user:
name: "{{ myapp_user }}"
group: "{{ myapp_user }}"
system: true
shell: /usr/sbin/nologin
home: "{{ myapp_root }}"
create_home: false
- name: Create application directories
ansible.builtin.file:
path: "{{ item }}"
state: directory
owner: "{{ myapp_user }}"
group: "{{ myapp_user }}"
mode: "0750"
loop:
- "{{ myapp_root }}"
- "{{ myapp_root }}/shared"
- "{{ myapp_root }}/releases"
- name: Render environment file
ansible.builtin.template:
src: app.env.j2
dest: "{{ myapp_root }}/shared/app.env"
owner: root
group: "{{ myapp_user }}"
mode: "0640"
no_log: true
diff: false
notify: Restart myapp
- name: Install systemd unit
ansible.builtin.template:
src: myapp.service.j2
dest: /etc/systemd/system/myapp.service
owner: root
group: root
mode: "0644"
notify:
- Reload systemd
- Restart myapp
- name: Enable and start service
ansible.builtin.systemd_service:
name: myapp
enabled: true
state: started
ignore_errors: "{{ ansible_check_mode }}"
# roles/myapp/templates/myapp.service.j2
[Unit]
Description=myapp
After=network-online.target
[Service]
User={{ myapp_user }}
EnvironmentFile={{ myapp_root }}/shared/app.env
ExecStart={{ myapp_exec_start }}
Restart=on-failure
[Install]
WantedBy=multi-user.target
# roles/myapp/handlers/main.yml
---
- name: Reload systemd
ansible.builtin.systemd_service:
daemon_reload: true
- name: Restart myapp
ansible.builtin.systemd_service:
name: myapp
state: restarted
ignore_errors: "{{ ansible_check_mode }}"
Unpacking a release under releases/ and switching the current symlink follows the same pattern and is omitted here. Handlers run in the order they are defined, not the order they were notified, so Reload systemd must sit above Restart myapp. They run at the end of each section of a play, once per host, however many tasks notified them. If a later task fails, pending handlers are lost for that host and the next run sees no change, so the restart never happens. Set force_handlers = True or call ansible.builtin.meta: flush_handlers before risky steps.
Use import_tasks when the file list is fixed: imports are resolved at parse time, so tags and --list-tasks work. Use include_tasks when the file depends on a runtime value.
An idempotent play reports changed=0 on its second run. If every run shows 40 changes, nobody can spot the one that matters. The usual offenders are command and shell tasks, which always report changed, and templates containing timestamps.
- name: Read current schema version
ansible.builtin.command:
cmd: "{{ myapp_root }}/current/bin/myapp db version"
register: myapp_schema
changed_when: false
check_mode: false
- name: Apply migrations
ansible.builtin.command:
cmd: "{{ myapp_root }}/current/bin/myapp db migrate"
register: myapp_migrate
changed_when: "'applied' in myapp_migrate.stdout"
when: myapp_schema.stdout | trim != myapp_target_schema
check_mode: false makes a read-only probe run even under --check. Without it, the task is skipped, myapp_schema.stdout is undefined, and the conditional on the next task fails. Prefer a real module to command; failing that, use creates or removes.
Run --check --diff as a preview of a change, and know its limits. Check mode cannot see the effects of tasks it simulated: if a package is not actually installed, a later task that reads its config file fails or gives a wrong answer. That is why the service tasks above use ignore_errors: "{{ ansible_check_mode }}", since the unit file does not exist yet on a fresh host. The ansible-lint ignore-errors rule accepts this specific form. Modules without check mode support are skipped, so a clean check run does not prove a clean real run.
Diff output prints file contents, so anything secret in a template appears in CI logs unless the task sets diff: false. no_log: true hides failure messages too, so keep it on as few tasks as possible.
Since the split of Ansible into ansible-core and collections, a module name has three parts: namespace, collection, and plugin. Always write ansible.builtin.copy, not copy. A short name is resolved through a search list, so an installed collection can shadow it. The play-level collections: keyword narrows the search but does not remove the ambiguity. The fqcn lint rule flags the short form, and ansible-lint --fix rewrites most cases.
# collections/requirements.yml
---
collections:
- name: amazon.aws
version: ">=12.1.0,<13.0.0"
- name: ansible.posix
version: ">=2.2.2,<3.0.0"
- name: community.general
version: ">=14.0.0,<15.0.0"
- name: community.hashi_vault
version: ">=7.1.0,<8.0.0"
ansible-galaxy collection install -r collections/requirements.yml -p collections/
ansible-galaxy collection list -p collections/
Ranges let minor updates arrive, but the install you tested and the install CI performs next month can differ. For full reproducibility, pin exact versions and update them through a scheduled pull request, or vendor the resolved set into an execution environment image built with ansible-builder. A private mirror or pre-built image also removes public Galaxy from the release path.
At scale, a hand-kept host list is wrong within hours. Inventory plugins query the source of truth at runtime. The filename matters: the AWS plugin only accepts files ending in aws_ec2.yml, and the Azure and GCP plugins (azure.azcollection.azure_rm, google.cloud.gcp_compute) require similar suffixes.
# inventories/production/aws_ec2.yml
---
plugin: amazon.aws.aws_ec2
regions:
- eu-west-1
filters:
instance-state-name: running
tag:Environment: production
tag-key: Role
hostnames:
- private-ip-address
compose:
ansible_host: private_ip_address
keyed_groups:
- key: tags.Role
prefix: role
- key: placement.availability_zone
prefix: az
groups:
canary: "tags.get('Canary') == 'true'"
strict: true
cache: true
cache_plugin: ansible.builtin.jsonfile
cache_connection: .cache/inventory
cache_timeout: 300
An instance tagged Role=web lands in role_web. strict: true makes a bad compose or keyed_groups expression fail rather than quietly dropping a host; the tag-key filter ensures every returned instance has the tag being keyed on. Group names built from values containing hyphens, such as zone names, are subject to the TRANSFORM_INVALID_GROUP_CHARS setting, so run --graph to see the names you actually get before referencing them.
Pointing -i at the directory merges every source in it, in ASCII filename order, and loads group_vars/ beside them. A static file adds stable abstractions on top of generated groups:
# inventories/production/static.yml
---
all:
children:
frontend:
children:
role_web:
role_lb:
Variables then live in group_vars/role_web.yml and are keyed to the tag rather than to host names, so a replacement instance picks up configuration without a code change.
Failure modes to plan for:
role_web empty, and the play is skipped with a warning and a zero exit code. The result looks like success. Gate deploys with a check on the expected host count.--flush-cache to ansible-playbook for deploys.Vault encrypts data at rest in the repository with AES-256. A widely used convention keeps secrets findable: put encrypted values in vault.yml with a vault_ prefix, and reference them from a plain vars.yml.
# group_vars/all/vars.yml (plain)
myapp_db_password: "{{ vault_myapp_db_password }}"
# group_vars/all/vault.yml (encrypted with ansible-vault)
vault_myapp_db_password: "replace-me"
ansible-vault create --vault-id prod@prompt inventories/production/group_vars/all/vault.yml
ansible-vault rekey --vault-id prod@prompt inventories/production/group_vars/all/vault.yml
ansible-vault encrypt_string --vault-id prod@prompt --stdin-name vault_api_token
Use one vault ID per environment so that a staging password cannot open production data. In CI, use a client script instead of a prompt: --vault-id prod@scripts/vault-pass-client.sh. An executable whose name ends in -client receives the vault ID as an argument and prints the password to stdout, so it can fetch it from your CI secret store. Vault's weaknesses are operational: one shared password, rotation that means re-encrypting and redistributing, no record of who read what, and merge conflicts on whole-file encrypted blobs.
| Approach | Secret lives in | Strength | Failure mode |
|---|---|---|---|
| Ansible Vault | Git, encrypted | No runtime dependency | Shared key, weak audit, painful rotation |
HashiCorp Vault or OpenBao (community.hashi_vault) | External service | Per-identity access, audit log, dynamic credentials | Deploys stop when the service is down |
| Cloud secret manager lookups | Provider service | IAM-based access, native rotation | Ties playbooks to a provider |
SOPS (community.sops) | Git, values encrypted, keys visible | Readable diffs, KMS or age keys | Another tool on every control node |
- name: Read database credentials
ansible.builtin.set_fact:
myapp_db_password: >-
{{ lookup('community.hashi_vault.vault_kv2_get', 'myapp/db',
engine_mount_point='secret').secret.password }}
no_log: true
delegate_to: localhost
run_once: true
Variables are lazy, so a lookup placed directly in group_vars runs every time it is referenced, on the control node, once per host and task. Resolve once with set_fact, as above; with run_once, the fact applies to all hosts in the current batch. -vvv output and fact caches can also expose values, so keep verbose runs out of shared CI logs.
By default a play runs each task on all hosts in parallel, so a bad release reaches everything at once. serial splits the play into batches that complete in full before the next starts. A list ramps batch size, so the first host works as a canary.
# playbooks/rolling_deploy.yml
---
- name: Migrate schema once before any host changes
hosts: role_web[0]
become: true
tasks:
- name: Run migrations
ansible.builtin.import_role:
name: myapp
tasks_from: migrate
- name: Rolling update of web tier
hosts: role_web
become: true
serial:
- 1
- "10%"
- "25%"
max_fail_percentage: 10
order: shuffle
pre_tasks:
- name: Drain host from load balancers
community.general.haproxy:
state: disabled
host: "{{ inventory_hostname }}"
backend: myapp
socket: /run/haproxy/admin.sock
drain: true
wait_interval: 5
wait_retries: 60
delegate_to: "{{ item }}"
loop: "{{ groups['role_lb'] }}"
roles:
- role: myapp
post_tasks:
- name: Wait for local health endpoint
ansible.builtin.uri:
url: "http://127.0.0.1:{{ myapp_port }}/healthz"
status_code: 200
register: myapp_health
until: myapp_health.status == 200
retries: 30
delay: 2
- name: Return host to load balancers
community.general.haproxy:
state: enabled
host: "{{ inventory_hostname }}"
backend: myapp
socket: /run/haproxy/admin.sock
wait: true
delegate_to: "{{ item }}"
loop: "{{ groups['role_lb'] }}"
tasks/migrate.yml holds the two migration tasks from the idempotence section, with myapp_target_schema set in group_vars. Migrating first assumes the schema change is backward compatible, because old code runs against the new schema until the last batch finishes. Handlers are flushed between roles and post_tasks, so the restart happens before the health check. A host that fails the check stays drained, which is the outcome you want.
The semantics have sharp edges:
max_fail_percentage is evaluated per batch and must be exceeded, not met. With a batch of 10 and a value of 10, one failure continues and two abort. With a batch of 1, one failure aborts. Small batches make the setting all-or-nothing.any_errors_fatal: true stops the whole play after the current task finishes on the batch. Use it where partial state is dangerous, such as database clusters.run_once combined with serial runs once per batch, not once per play. That is why the migration lives in its own play. --limit is applied after the [0] subscript, so a limited run can match no host and skip the migration.throttle caps concurrency on a single task, such as an artifact download that would saturate a shared service.
Measure first. The profile_tasks callback enabled earlier prints the slowest tasks. Then work through these settings, roughly in order of payoff:
| Setting | Effect | Trade-off |
|---|---|---|
forks | Parallel hosts per task; default is 5 | Each fork is a process using control node memory; too high causes swapping and SSH timeouts |
pipelining | Runs modules over stdin, avoiding temp file transfers | Requires that sudoers does not enforce requiretty; does not affect copy file transfers |
ControlPersist | Reuses SSH connections | Stale sockets after a host is replaced at the same address |
Fact caching, gathering = smart | Skips repeated setup | Cached facts can be stale and are stored in plain text |
gather_subset, gather_facts: false | Reduces per-host startup time | Roles that need facts must gather them |
async with poll | Long tasks do not hold a fork | Needs a follow-up status task and clear handling of timeouts |
Prefer passing a list to ansible.builtin.package over looping one package at a time, since a loop makes one module call per item. Run the control node close to the managed hosts.
Mitogen for Ansible replaces the SSH transport with a persistent Python process on each target and can cut execution time on module-heavy plays. It installs as a strategy plugin, using strategy_plugins and strategy = mitogen_linear. The caveats are real. It hooks into ansible-core internals, so support lags new core versions, which conflicts with staying current. Its behaviour around become, connection variables, and some plugins differs from stock Ansible, and failures are harder to diagnose. Check the compatibility notes for your exact versions, try pipelining and profiling first, and treat it as an optional accelerator you can remove without changing any playbook.
Lint in CI on every change. Profiles offer a graded path: start lower on an old codebase and raise the bar over time. The available profiles are min, basic, moderate, safety, shared, and production.
# .ansible-lint
---
profile: production
exclude_paths:
- collections/
- .cache/
skip_list:
- galaxy[no-changelog]
warn_list:
- experimental
offline: true
Rules worth understanding rather than silencing include no-changed-when, risky-file-permissions (a missing mode leaves the result to umask and module defaults), no-handler, and command-instead-of-module. Exempt a single line with a trailing noqa comment and a reason, not with a global skip. Lint says nothing about whether a role works, which is what Molecule is for.
Molecule creates a throwaway instance, applies the role, applies it again to check idempotence, and runs assertions. Testing a systemd service in a container needs an image with an init system, so build one once:
# roles/myapp/molecule/default/Dockerfile
FROM debian:13
RUN apt-get update \
&& apt-get install -y --no-install-recommends python3 sudo systemd systemd-sysv
CMD ["/lib/systemd/systemd"]
docker build -t myproject/debian13-systemd:latest roles/myapp/molecule/default
# roles/myapp/molecule/default/molecule.yml
---
role_name_check: 1
dependency:
name: galaxy
enabled: false
driver:
name: docker
platforms:
- name: debian13
image: myproject/debian13-systemd:latest
pre_build_image: true
pull: false
command: /lib/systemd/systemd
privileged: true
cgroupns_mode: host
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
verifier:
name: ansible
# roles/myapp/molecule/default/converge.yml
---
- name: Converge
hosts: all
become: true
vars:
myapp_db_password: molecule-only
myapp_exec_start: /bin/sleep infinity
pre_tasks:
- name: Refresh apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
roles:
- role: myapp
# roles/myapp/molecule/default/verify.yml
---
- name: Verify
hosts: all
become: true
gather_facts: false
tasks:
- name: Collect service state
ansible.builtin.service_facts:
- name: Read environment file metadata
ansible.builtin.stat:
path: /srv/app/shared/app.env
register: myapp_env
- name: Assert service and file state
ansible.builtin.assert:
that:
- ansible_facts.services['myapp.service'].state == 'running'
- ansible_facts.services['myapp.service'].status == 'enabled'
- myapp_env.stat.exists
- myapp_env.stat.mode == '0640'
The stub ExecStart shows why it pays to expose that value in defaults: the role can be tested without a real release. Dependency installation is disabled because this role uses only ansible.builtin; a role that needs collections should have them installed in the CI step before Molecule runs. The role_name_check: 1 setting downgrades the fully qualified role name check to a warning, which suits roles not published to Galaxy.
cd roles/myapp
molecule test # full lifecycle, including destroy
molecule converge # apply and keep the instance
molecule verify # run assertions only
molecule login --host debian13 # shell into the instance
molecule test --destroy never # keep the instance after a failure
molecule destroy
Know what this leaves untested. A privileged container shares the host kernel, so kernel modules, sysctl, firewall rules, and boot ordering behave differently. Rolling logic and real load balancers need a staging environment. Molecule's configuration schema has moved over time, with a top-level ansible section superseding parts of provisioner, so check the docs for your installed version when you upgrade.
Run these in order before merging, and again before a production deploy.
# Toolchain and effective configuration
ansible --version
ansible-config dump --only-changed
ansible-galaxy collection list -p collections/
# Inventory matches reality
ansible-inventory -i inventories/production --graph
ansible-inventory -i inventories/production --host 10.0.1.25 --yaml
ansible -i inventories/production role_web -m ansible.builtin.ping
# Static checks
ansible-lint
ansible-playbook -i inventories/production playbooks/site.yml --syntax-check
ansible-playbook -i inventories/production playbooks/rolling_deploy.yml --list-hosts --list-tasks
# Role behaviour
(cd roles/myapp && molecule test)
# Preview, then a canary
ansible-playbook -i inventories/production playbooks/site.yml --check --diff --limit canary
ansible-playbook -i inventories/production playbooks/rolling_deploy.yml --limit canary
# Idempotence on a real host: expect changed=0
ansible-playbook -i inventories/production playbooks/site.yml --limit canary
--graph output contains every expected group, with the expected host count, and no group is unexpectedly empty.git grep -L '\$ANSIBLE_VAULT' -- '*vault.yml' prints nothing, showing that no vault file is committed unencrypted.--diff run in CI contains no secret values.Found a mistake in this tutorial? Let us know and we will fix it.