Ansible Style Guide
crefft > Infrastructure > Ansible
The key words “MUST”, “MUST NOT”, “REQUIRED”, “SHALL”, “SHALL NOT”, “SHOULD”, “SHOULD NOT”, “RECOMMENDED”, “MAY”, and “OPTIONAL” in this document are to be interpreted as described in RFC 2119.
Target Version: Ansible Core 2.18+ with Python 3.12+
Quick Reference
| Task | Tool | Command |
|---|---|---|
| Install | pipx | pipx install --include-deps ansible-core==2.21.5 |
| Install dev tools | pipx | pipx install ansible-dev-tools==26.10.0 |
| Lint | ansible-lint1 | ansible-lint |
| YAML lint | yamllint2 | yamllint . |
| Test roles | Molecule3 | molecule test |
| Syntax check | ansible-playbook | ansible-playbook --syntax-check playbook.yml |
| Dry run | ansible-playbook | ansible-playbook --check playbook.yml |
| List tasks | ansible-playbook | ansible-playbook --list-tasks playbook.yml |
| Vault encrypt | ansible-vault | ansible-vault encrypt vars/secrets.yml |
| SOPS encrypt | sops4 | sops -e vars/secrets.yml |
| Galaxy install | ansible-galaxy | ansible-galaxy collection install -r requirements.yml |
Why Ansible
Why Ansible for infrastructure automation?
- Agentless: No software required on managed nodes (uses SSH/WinRM)
- YAML-based: Human-readable, declarative configuration
- Idempotent: Safe to run multiple times without side effects
- Extensive modules: 6,000+ built-in modules across collections
- Low barrier to entry: Simple syntax with powerful capabilities
- Strong ecosystem: Large community, Ansible Galaxy collections, Red Hat support
Ansible excels at configuration management, application deployment, and orchestration across infrastructure fleets without the complexity of agent-based solutions like Puppet or Chef.
Python Version Requirements
Ansible Core 2.18 MUST use Python 3.11+ for the control node (where Ansible runs) and Python 3.8+ for managed nodes (targets)5.
Why these requirements? Python 3.10 reached end-of-life in 2024, and Ansible 2.18 leverages newer Python features for improved performance and maintainability. Target nodes running Python 3.7 are no longer supported.
# Verify Python version on control node
python3 --version # Should be 3.12+
# Install Ansible Core with pipx (recommended)
pipx install --include-deps ansible-core==2.21.5
# Or install ansible-dev-tools (includes ansible-lint, molecule, etc.)
pipx install ansible-dev-tools==26.10.0
Project Structure
Projects MUST follow this directory layout for maintainability and clarity:
ansible-project/
├── ansible.cfg # Ansible configuration
├── .ansible-lint # Linting configuration
├── requirements.yml # Galaxy collections/roles
├── inventories/
│ ├── production/
│ │ ├── hosts.yml # Production inventory (YAML format)
│ │ ├── group_vars/ # Loaded: adjacent to this inventory
│ │ │ ├── all.yml
│ │ │ └── webservers.yml
│ │ └── host_vars/
│ │ └── web01.yml # Host-specific variables
│ └── staging/
│ ├── hosts.yml
│ └── group_vars/
│ └── all.yml
├── playbooks/
│ ├── site.yml # Master playbook
│ ├── webservers.yml # Server-specific playbooks
│ ├── deploy.yml
│ └── group_vars/ # Loaded: adjacent to these playbooks
│ └── all.yml
├── roles/
│ ├── common/ # Shared base configuration
│ │ ├── tasks/
│ │ │ └── main.yml
│ │ ├── handlers/
│ │ │ └── main.yml
│ │ ├── templates/
│ │ ├── files/
│ │ ├── vars/
│ │ │ └── main.yml
│ │ ├── defaults/
│ │ │ └── main.yml
│ │ ├── meta/
│ │ │ └── main.yml
│ │ ├── molecule/ # Molecule testing
│ │ │ └── default/
│ │ │ ├── molecule.yml
│ │ │ ├── converge.yml
│ │ │ └── verify.yml
│ │ └── README.md
│ ├── nginx/
│ └── postgresql/
├── collections/
│ └── requirements.yml # Collection dependencies
├── filter_plugins/ # Custom Jinja2 filters
├── vault/
│ └── secrets.yml # Encrypted secrets (ansible-vault)
└── .github/
└── workflows/
└── ansible-lint.yml # CI/CD integration
Why this structure?
- Clear separation between environments (
inventories/) - Reusable roles for common configurations (
roles/) - Variables live beside the inventory that owns them (
inventories/*/group_vars/) - Logical grouping of playbooks by purpose
- Integrated testing with Molecule
- CI/CD ready with GitHub Actions
Where Ansible Loads group_vars and host_vars
group_vars/ and host_vars/ directories MUST be placed next to the
selected inventory source or next to the playbook. Ansible loads them from
exactly those two locations6 and nowhere else.
Why? A group_vars/ directory in the project root is loaded only when
the playbook itself is in the project root. If playbooks live in
playbooks/, a root-level group_vars/all.yml is silently ignored — the
variables are simply undefined at run time, so a task either fails with an
undefined-variable error or, worse, falls back to a default() value.
# Loaded when running:
# ansible-playbook -i inventories/production/hosts.yml playbooks/site.yml
inventories/production/group_vars/all.yml # inventory-adjacent: loaded
playbooks/group_vars/all.yml # playbook-adjacent: loaded
group_vars/all.yml # project root: IGNORED
Variables shared by every environment SHOULD live in role
defaults/main.yml, or be duplicated deliberately per inventory. Ansible
provides no project-wide “global” variable directory.
Linting & Formatting
ansible-lint
All playbooks and roles MUST pass ansible-lint1 checks. Ansible-lint promotes proven practices and prevents common pitfalls.
# Install (comes with ansible-dev-tools)
pipx install ansible-lint==26.8.0
# Run on entire project
ansible-lint
# Run on specific playbook
ansible-lint playbooks/webservers.yml
# Run with auto-fix
ansible-lint --fix
Configuration (.ansible-lint):
---
# ansible-lint configuration
# Schema: https://docs.ansible.com/projects/lint/configuring/
# Enable all rules by default, opt-out problematic ones
skip_list:
- yaml[line-length] # Allow longer lines for readability
- role-name[path] # Allow flexible role naming
# Enable opt-in rules
enable_list:
- args # Check task arguments
- empty-string-compare # Avoid comparing to empty strings
- no-log-password # Ensure no_log for passwords
- no-same-owner # Avoid redundant owner/group
- name[prefix] # Require task name prefixes
# Use production profile (strictest)
profile: production
# Exclude paths
exclude_paths:
- .github/
- .ansible_cache/
- molecule/
- vault/
# Prefix template for name[prefix]; {stem} is the task file name
task_name_prefix: "{stem} | "
# Offline mode (don't check Galaxy)
offline: false
Every key MUST exist in the ansible-lint configuration schema, and every
value MUST use the type that schema declares. ansible-lint 26.8.0
validates .ansible-lint against a closed JSON schema and exits 3 on any
unknown key or wrong value type, so a typo stops the linter rather than
loosening a rule.
The minimum ansible-core version MUST NOT be set here; there is no
min_ansible_version key. Declare it in meta/runtime.yml instead, which is
what the meta-runtime rule checks:
# meta/runtime.yml
---
requires_ansible: ">=2.18.0"
Why? .ansible-lint configures the linter; meta/runtime.yml declares
what the content itself supports. Only the latter travels with the role or
collection when it is published to Galaxy.
Why ansible-lint? It catches common mistakes like missing task names, deprecated syntax, security issues (exposed passwords), and non-idempotent operations before they cause production problems.
yamllint
All YAML files MUST pass yamllint2 checks for consistency.
# Install
pipx install yamllint==1.35.1
# Lint all YAML files
yamllint .
Configuration (.yamllint):
---
extends: default
rules:
line-length:
max: 120
level: warning
indentation:
spaces: 2
indent-sequences: true
comments:
min-spaces-from-content: 1
braces:
max-spaces-inside: 1
brackets:
max-spaces-inside: 1
truthy:
allowed-values: ['true', 'false', 'yes', 'no']
ignore: |
.github/
.ansible_cache/
vault/
Pre-commit Hooks
Projects SHOULD use pre-commit hooks to enforce quality standards:
# .pre-commit-config.yaml
repos:
- repo: https://github.com/ansible/ansible-lint
rev: v25.11.1
hooks:
- id: ansible-lint
args: [--profile=production]
- repo: https://github.com/adrienverge/yamllint
rev: v1.35.1
hooks:
- id: yamllint
args: [-c=.yamllint]
- repo: https://github.com/pre-commit/pre-commit-hooks
rev: v5.0.0
hooks:
- id: trailing-whitespace
- id: end-of-file-fixer
- id: check-yaml
args: [--unsafe] # Allow ansible-vault encrypted files
- id: check-added-large-files
# Install pre-commit
pipx install pre-commit==4.6.2
# Install hooks
pre-commit install
# Run manually
pre-commit run --all-files
Inventory Management
Static Inventory (YAML Format)
Static inventories SHOULD use YAML format for clarity and consistency:
# inventories/production/hosts.yml
---
all:
vars:
# Global variables for all hosts
ansible_user: deploy
ansible_become: true
domain: example.com
children:
webservers:
hosts:
web01.example.com:
ansible_host: 192.168.1.10
http_port: 80
web02.example.com:
ansible_host: 192.168.1.11
http_port: 80
vars:
nginx_worker_processes: 4
nginx_max_clients: 200
databases:
hosts:
db01.example.com:
ansible_host: 192.168.1.20
# Pin the major explicitly; see the platform support matrix below
postgresql_version: "16"
vars:
postgresql_max_connections: 100
# Group of groups: every child MUST be a mapping key, not a bare scalar
production:
children:
webservers:
databases:
vars:
environment: production
Child group names under children MUST be written as mapping keys with a
trailing colon. Consecutive bare scalars are folded by the YAML parser into a
single multi-word string, so webservers and databases become one child
group literally named webservers databases.
Why does this matter? ansible-inventory exits successfully either way.
The damage is silent: production ends up with one child group that contains
no hosts, so ansible-playbook --limit production reports zero matched hosts
and the deployment quietly skips every server it was supposed to reach. Run
every inventory through ansible-inventory --graph and assert the expected
hosts appear under each group.
Why YAML over INI? YAML supports nested structures, lists, and complex data types, making it more suitable for modern infrastructure-as-code practices.
Dynamic Inventory
Dynamic inventories SHOULD be used for cloud environments to automatically discover hosts:
# inventories/aws_ec2.yml (AWS dynamic inventory)
---
plugin: amazon.aws.aws_ec2
regions:
- us-east-1
- us-west-2
filters:
tag:Environment: production
instance-state-name: running
keyed_groups:
# Group by instance type
- key: instance_type
prefix: instance_type
# Group by tag Role
- key: tags.Role
prefix: role
# Group by availability zone
- key: placement.availability_zone
prefix: az
hostnames:
- dns-name
- private-ip-address
compose:
ansible_host: public_ip_address
ansible_user: "'ubuntu' if platform_details.endswith('Ubuntu') else 'ec2-user'"
# List dynamic inventory
ansible-inventory -i inventories/aws_ec2.yml --list
# Use in playbook
ansible-playbook -i inventories/aws_ec2.yml playbooks/webservers.yml
Supported dynamic inventory plugins7. Each plugin ships in a collection
that MUST be declared in requirements.yml before the plugin is used:
| Plugin | Collection | Pinned version |
|---|---|---|
amazon.aws.aws_ec2 | amazon.aws | 11.4.0 |
azure.azcollection.azure_rm | azure.azcollection | 4.0.0 |
google.cloud.gcp_compute | google.cloud | 1.14.0 |
openstack.cloud.openstack | openstack.cloud | 2.6.0 |
community.vmware.vmware_vm_inventory | community.vmware | 6.3.0 |
Why declare the collection? enable_plugins in ansible.cfg names a
plugin, it does not install one. An inventory plugin whose collection is
absent makes ansible-inventory fall through to the next parser and report an
empty inventory, so a playbook targets nothing instead of failing loudly.
Group Structure Best Practices
Groups SHOULD be organized by:
- Environment (production, staging, development)
- Function (webservers, databases, loadbalancers)
- Location (us-east, eu-west)
- Provider (aws, azure, on-prem)
# Multi-dimensional grouping
all:
children:
# By environment
production:
children:
prod_webservers:
prod_databases:
staging:
children:
staging_webservers:
# By function
webservers:
children:
prod_webservers:
staging_webservers:
# By location
us_east:
children:
us_east_webservers:
us_east_databases:
Playbook Best Practices
Playbook Structure
Playbooks MUST include descriptive names and follow this structure:
---
# playbooks/webservers.yml
- name: Configure web servers
hosts: webservers
become: true
gather_facts: true
vars:
http_port: 80
max_clients: 200
pre_tasks:
- name: Update apt cache
ansible.builtin.apt:
update_cache: true
cache_valid_time: 3600
when: ansible_os_family == "Debian"
roles:
- role: common
- role: nginx
vars:
nginx_port: "{{ http_port }}"
tasks:
- name: Ensure nginx service is running
ansible.builtin.service:
name: nginx
state: started
enabled: true
post_tasks:
- name: Verify web server is responding
ansible.builtin.uri:
url: "http://{{ inventory_hostname }}"
status_code: 200
delegate_to: localhost
handlers:
- name: Restart nginx
ansible.builtin.service:
name: nginx
state: restarted
Task Formatting
All tasks MUST:
- Include a descriptive
namefield - Use Fully Qualified Collection Names (FQCN)8
- Use YAML dictionary format (not inline
key=value)
# GOOD: FQCN with descriptive name and dict format
- name: Install nginx web server
ansible.builtin.apt:
name: nginx
state: present
update_cache: true
# BAD: No FQCN, missing name, inline format
- apt: name=nginx state=present
Why FQCN? Fully Qualified Collection Names (e.g., ansible.builtin.copy
instead of copy) eliminate ambiguity about which module is being used,
especially when multiple collections provide similar modules. Required in
Ansible 2.10+.
Task Naming Conventions
Task names MUST be descriptive and follow these conventions:
# GOOD: Clear, action-oriented names
- name: Install PostgreSQL 16 server
- name: Deploy application configuration to /etc/app/config.yml
- name: Restart nginx service after config change
- name: Verify database connection is responding
# BAD: Vague or missing names
- name: Install package
- name: Copy file
- name: Run command
- apt: name=nginx state=present # No name at all
Naming pattern: <Action> <What> [to/from/in <Where>] [<Additional context>]
Tags and Selective Execution
Tasks SHOULD use tags for selective playbook execution:
- name: Install nginx package
ansible.builtin.apt:
name: nginx
state: present
tags:
- nginx
- install
- packages
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
notify: Reload nginx
tags:
- nginx
- config
# Run only tasks tagged 'config'
ansible-playbook playbooks/webservers.yml --tags config
# Skip tasks tagged 'install'
ansible-playbook playbooks/webservers.yml --skip-tags install
# Run multiple tags
ansible-playbook playbooks/webservers.yml --tags "nginx,config"
Standard tag conventions:
install- Package installationconfig- Configuration file deploymentservice- Service managementverify- Verification/testing tasksnever- Tasks that only run when explicitly requested
Handlers and Notifications
Handlers SHOULD be used for service restarts and one-time operations triggered by changes:
# tasks/main.yml
- name: Deploy nginx configuration
ansible.builtin.template:
src: nginx.conf.j2
dest: /etc/nginx/nginx.conf
validate: 'nginx -t -c %s'
notify:
- Reload nginx
- Clear nginx cache
# handlers/main.yml
- name: Reload nginx
ansible.builtin.service:
name: nginx
state: reloaded
- name: Clear nginx cache
ansible.builtin.file:
path: /var/cache/nginx
state: absent
Why handlers? Handlers ensure services are restarted only once even if multiple tasks trigger changes, and they run at the end of the play for efficiency.
Error Handling (block/rescue/always)
Complex error handling SHOULD use block/rescue/always:
- name: Deploy application with rollback capability
block:
- name: Stop application service
ansible.builtin.service:
name: myapp
state: stopped
- name: Backup current version
ansible.builtin.copy:
src: /opt/myapp/app.jar
dest: /opt/myapp/app.jar.backup
remote_src: true
- name: Deploy new version
ansible.builtin.copy:
src: app-v2.jar
dest: /opt/myapp/app.jar
- name: Start application service
ansible.builtin.service:
name: myapp
state: started
- name: Verify application health
ansible.builtin.uri:
url: http://localhost:8080/health
status_code: 200
retries: 5
delay: 10
rescue:
- name: Rollback to previous version
ansible.builtin.copy:
src: /opt/myapp/app.jar.backup
dest: /opt/myapp/app.jar
remote_src: true
# MUST be restarted, not started: the service is already running the
# failed version, and `started` is a no-op on a running service
- name: Restart application with previous version
ansible.builtin.service:
name: myapp
state: restarted
- name: Verify the restored version is healthy
ansible.builtin.uri:
url: http://localhost:8080/health
status_code: 200
retries: 5
delay: 10
- name: Fail deployment
ansible.builtin.fail:
msg: "Deployment failed, rolled back to previous version"
always:
# ansible.builtin.file does NOT expand shell globs; find the paths first
- name: Find temporary deployment directories
ansible.builtin.find:
paths: /tmp
patterns: 'deploy-*'
file_type: any
register: deploy_scratch
- name: Clean up temporary files
ansible.builtin.file:
path: "{{ item.path }}"
state: absent
loop: "{{ deploy_scratch.files }}"
loop_control:
label: "{{ item.path }}"
Why restarted in the rescue block? ansible.builtin.service treats
started as an idempotent action that runs no command when the service is
already up9. The health check only fails after the service has started,
so at rescue time the failed version is still running. Restoring the previous
artifact on disk and then asking for started reports changed=0 and leaves
the broken build serving traffic — the rollback appears to succeed while
changing nothing. restarted always bounces the service, and the follow-up
health check proves the restored version actually works.
Why find instead of a wildcard path? ansible.builtin.file receives a
literal pathname and performs no glob expansion. Passing /tmp/deploy-*
makes Ansible look for a file whose name contains an asterisk, find nothing,
and report ok with changed=0 while every matching directory survives.
When to use:
block- Group related tasks that should succeed togetherrescue- Handle failures with recovery actions (like rollbacks)always- Cleanup tasks that run regardless of success/failure
Retries for Transient Failures
Tasks prone to transient failures SHOULD use retries:
- name: Download package from internet
ansible.builtin.get_url:
url: https://example.com/package.tar.gz
dest: /tmp/package.tar.gz
retries: 3
delay: 5
register: download_result
until: download_result is succeeded
- name: Wait for service to be ready
ansible.builtin.uri:
url: http://localhost:8080/health
status_code: 200
retries: 30
delay: 10
until: result.status == 200
register: result
Role Development
When to Create Roles
Roles SHOULD be created when:
- Configuration is reusable across multiple playbooks
- Task complexity exceeds 20-30 lines
- Multiple related configuration files exist
- You need to test configurations independently
Roles SHOULD NOT be created for:
- Single-use tasks (use playbook tasks instead)
- Very simple configurations (2-3 tasks)
Role Structure
Roles MUST follow Ansible’s standard directory structure10:
roles/nginx/
├── tasks/
│ └── main.yml # Primary task entry point
├── handlers/
│ └── main.yml # Event handlers (service restarts, etc.)
├── templates/
│ └── nginx.conf.j2 # Jinja2 templates
├── files/
│ └── index.html # Static files
├── vars/
│ └── main.yml # Internal variables (high priority)
├── defaults/
│ └── main.yml # Default variables (low priority)
├── meta/
│ └── main.yml # Role metadata and dependencies
├── molecule/ # Testing configuration
│ └── default/
│ ├── molecule.yml
│ ├── converge.yml
│ └── verify.yml
└── README.md # Role documentation
Defaults vs Vars
defaults/main.yml SHOULD contain:
- User-configurable parameters
- Sensible default values
- Variables meant to be overridden by users
vars/main.yml SHOULD contain:
- Internal implementation details
- Constants that shouldn’t be overridden
- Platform-specific values
# roles/nginx/defaults/main.yml (user-facing, overridable)
---
nginx_port: 80
nginx_worker_processes: auto
nginx_worker_connections: 1024
nginx_max_clients: 200
nginx_enable_ssl: false
nginx_ssl_protocols: "TLSv1.2 TLSv1.3"
# roles/nginx/vars/main.yml (internal, not meant to be changed)
---
nginx_config_path: /etc/nginx/nginx.conf
nginx_pid_file: /var/run/nginx.pid
nginx_log_dir: /var/log/nginx
nginx_package_name: nginx
Why? Ansible’s variable precedence11 makes defaults the lowest
priority (easily overridden by users) while vars has higher priority
(protected from accidental override).
Role Dependencies
Role dependencies SHOULD be declared in meta/main.yml:
# roles/nginx/meta/main.yml
---
dependencies:
- role: common
vars:
common_packages:
- curl
- vim
galaxy_info:
author: Your Name
description: Nginx web server configuration
company: Your Company
license: MIT
min_ansible_version: "2.18"
platforms:
- name: Ubuntu
versions:
- jammy # 22.04 LTS, compatibility target
- noble # 24.04 LTS
- resolute # 26.04 LTS, current
- name: Debian
versions:
- bookworm # 12, oldstable LTS, compatibility target
- trixie # 13, current stable
galaxy_tags:
- web
- nginx
Platform Support Matrix
Role metadata, Molecule platforms, and CI matrices MUST agree, and MUST NOT list a release that is past its free security-support date.
| Platform | Status on 2026-09-08 | Support ends | Include? |
|---|---|---|---|
| Debian 13 (trixie) | Current stable | 2028-08-09 (LTS 2030-06-30) | Yes, default |
| Debian 12 (bookworm) | Oldstable, LTS | 2028-06-30 | Yes, compatibility |
| Debian 11 (bullseye) | LTS ended 2026-08-31 | Expired | No |
| Ubuntu 26.04 (resolute) | Current LTS | 2031-04 | Yes, default |
| Ubuntu 24.04 (noble) | LTS | 2029-04 | Yes |
| Ubuntu 22.04 (jammy) | LTS | 2027-04 | Yes, compatibility |
| Ubuntu 20.04 (focal) | Standard support ended 2025-05-31 | ESM only | No |
Why drop Bullseye and Focal? Debian 11 left LTS on 31 August 2026 and
Ubuntu 20.04 left standard support on 31 May 2025; both now need a paid
extended-support subscription for security updates12. Advertising them in
platforms tells Galaxy users the role is tested on a distribution that no
longer receives free patches.
Database majors follow their own policy. PostgreSQL supports each major for five years, so 16 remains supported until 9 November 2028 while 18 is current13. Pin a major deliberately and track the current minor within it; do not treat “not the newest major” as an upgrade defect.
Argument Specs and Validation
Roles SHOULD define argument specs for validation and documentation14:
# roles/nginx/meta/argument_specs.yml
---
argument_specs:
main:
short_description: Nginx web server role
description: Installs and configures Nginx web server
author: Your Name
options:
nginx_port:
type: int
required: false
default: 80
description: Port for Nginx to listen on
nginx_worker_processes:
type: str
required: false
default: "auto"
description: Number of worker processes (or 'auto')
choices:
- "auto"
- "1"
- "2"
- "4"
nginx_enable_ssl:
type: bool
required: false
default: false
description: Enable SSL/TLS support
nginx_ssl_certificate:
type: path
required: false
description: Path to SSL certificate file
nginx_ssl_key:
type: path
required: false
description: Path to SSL private key file
Benefits:
- Automatic validation of role variables
- Self-documenting roles
- Better error messages for invalid inputs
- IDE autocomplete support
Collections
Using Collections
Collections SHOULD be installed from Galaxy or Git:
# collections/requirements.yml
---
collections:
# From Ansible Galaxy
- name: community.general
version: ">=9.0.0"
- name: community.sops
version: ">=1.8.0"
- name: ansible.posix
version: ">=1.6.0"
# From Git repository
- name: https://github.com/organization/collection.git
type: git
version: main
# Install collections
ansible-galaxy collection install -r collections/requirements.yml
# Install to specific path
ansible-galaxy collection install -r collections/requirements.yml -p ./collections
Collection-based Project Organization
Large projects MAY use collection structure for better organization:
my_namespace/my_collection/
├── plugins/
│ ├── modules/
│ ├── inventory/
│ └── filter/
├── roles/
│ ├── role1/
│ └── role2/
├── playbooks/
├── docs/
├── tests/
├── galaxy.yml
└── README.md
Idempotency
All tasks MUST be idempotent - safe to run multiple times without changing the result beyond the initial run.
Ensuring Idempotency
# GOOD: Idempotent - running multiple times has same effect
- name: Ensure nginx is installed
ansible.builtin.apt:
name: nginx
state: present
- name: Ensure log directory exists with correct permissions
ansible.builtin.file:
path: /var/log/myapp
state: directory
mode: '0755'
owner: appuser
group: appuser
# BAD: Not idempotent - appends line every time
- name: Add log entry
ansible.builtin.shell: echo "Deployed at $(date)" >> /var/log/deploy.log
# GOOD: Idempotent - the line content is stable, so lineinfile matches it
# on the second run and reports no change
- name: Record the deployed release
ansible.builtin.lineinfile:
path: /var/log/deploy.log
line: "Deployed {{ app_version }}"
create: true
mode: '0644'
# INTENTIONALLY CHANGING: a timestamp differs on every run, so it can never
# be idempotent. Use regexp so the single marker line is replaced rather
# than accumulated, and accept that the task always reports changed.
- name: Update the deployment timestamp marker
ansible.builtin.lineinfile:
path: /var/run/myapp/last-deploy
regexp: '^Deployed '
line: "Deployed {{ ansible_facts.date_time.iso8601 }}"
create: true
mode: '0644'
# BAD: Not idempotent - adds user to group every time
- name: Add user to docker group
ansible.builtin.command: usermod -aG docker myuser
# GOOD: Idempotent version
- name: Ensure user is in docker group
ansible.builtin.user:
name: myuser
groups: docker
append: true
changed_when and failed_when
Use changed_when to control when tasks report changes:
- name: Check if reboot is required
ansible.builtin.stat:
path: /var/run/reboot-required
register: reboot_required
changed_when: false # Never report as changed
- name: Get current kernel version
ansible.builtin.command: uname -r
register: kernel_version
changed_when: false
- name: Run database migration
ansible.builtin.command: ./manage.py migrate
register: migration_result
changed_when: '"No migrations to apply" not in migration_result.stdout'
failed_when:
- migration_result.rc != 0
- '"already exists" not in migration_result.stderr'
Why? Properly using changed_when and failed_when makes playbook
output accurate and prevents false positives in CI/CD pipelines.
Timestamps Are Never Idempotent
A task that writes the current time MUST NOT be presented as idempotent.
lineinfile matches on the rendered line, so a fresh timestamp never matches
the previous one: the file grows by one line per run and the task reports
changed every time.
Record a value that is stable between deployments — a release version, a
commit SHA — or, when the timestamp itself is the point, add regexp so the
marker line is replaced in place and label the task as intentionally
changing.
Facts MUST be read through ansible_facts rather than the injected
top-level ansible_date_time variable. INJECT_FACTS_AS_VARS defaulting to
True is deprecated and the top-level names are removed in ansible-core
2.24.
Security
Secrets Management
Ansible Vault
All secrets MUST be encrypted with ansible-vault or SOPS. For simple use cases, use ansible-vault15:
# Create encrypted file
ansible-vault create vault/production.yml
# Edit encrypted file
ansible-vault edit vault/production.yml
# Encrypt existing file
ansible-vault encrypt group_vars/production.yml
# View encrypted file
ansible-vault view vault/production.yml
# Rekey (change password)
ansible-vault rekey vault/production.yml
# vault/production.yml (encrypted)
---
database_password: "SuperSecretPassword123"
api_key: "sk-1234567890abcdef"
ssl_private_key: |
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----
Vault password MUST be stored securely (never committed to git):
# Store in password manager, use --vault-password-file
ansible-playbook playbook.yml --vault-password-file ~/.ansible-vault-pass
# Or use environment variable
export ANSIBLE_VAULT_PASSWORD_FILE=~/.ansible-vault-pass
# Use multiple vault IDs for different environments
ansible-playbook playbook.yml \
--vault-id prod@~/.vault-prod \
--vault-id staging@~/.vault-staging
SOPS for GitOps
For GitOps workflows and multi-cloud environments, SOPS (Secrets OPerationS)4 is RECOMMENDED over ansible-vault:
# macOS: Homebrew tracks the current release
brew install sops
# Linux: pin the version, select the architecture, verify the checksum.
# -f makes curl exit non-zero on an HTTP error instead of saving the
# error page as the binary.
SOPS_VERSION=3.13.3
SOPS_ARCH=amd64 # or arm64
SOPS_BASE="https://github.com/getsops/sops/releases/download/v${SOPS_VERSION}"
curl -fsSLO "${SOPS_BASE}/sops-v${SOPS_VERSION}.linux.${SOPS_ARCH}"
curl -fsSLO "${SOPS_BASE}/sops-v${SOPS_VERSION}.checksums.txt"
sha256sum --check --ignore-missing "sops-v${SOPS_VERSION}.checksums.txt"
sudo install -m 0755 "sops-v${SOPS_VERSION}.linux.${SOPS_ARCH}" /usr/local/bin/sops
# Optionally verify the Sigstore bundle published with the release
cosign verify-blob \
--bundle "sops-v${SOPS_VERSION}.checksums.sigstore.json" \
--certificate-identity-regexp 'https://github\.com/getsops/sops/.*' \
--certificate-oidc-issuer https://token.actions.githubusercontent.com \
"sops-v${SOPS_VERSION}.checksums.txt"
# Install community.sops collection
ansible-galaxy collection install community.sops:2.4.0
Release artifacts MUST be pinned to a version and verified before
installation. The unversioned releases/latest/download/sops-linux-amd64
path returns HTTP 404: SOPS renamed its assets to include the version and
platform, so the old name no longer exists in any current release. Without
-f, curl cheerfully writes GitHub’s 404 page to disk and chmod +x makes
it executable.
Why SOPS over ansible-vault?16
- Supports multiple key management systems (AWS KMS, GCP KMS, Azure Key Vault, PGP, age)
- Encrypts only values, leaving keys visible for diff/grep
- Not tied to Ansible ecosystem (works with Terraform, Kubernetes, etc.)
- Better suited for multi-cloud DevSecOps pipelines
- Native GitOps support
SOPS Configuration (.sops.yaml):
Creation rules MUST encrypt every value except an explicit metadata
allow-list. Use unencrypted_regex to name the handful of keys that stay
readable, rather than encrypted_regex to name the keys that get protected.
---
creation_rules:
# Production secrets use AWS KMS
- path_regex: vault/production\.ya?ml$
kms: arn:aws:kms:us-east-1:123456789:key/abc-def-123
unencrypted_regex: "^(app_name|environment|sops)$"
# Staging secrets use PGP
- path_regex: vault/staging\.ya?ml$
pgp: "FBC7B9E2A4F9289AC0C1D4843D16CEE4A27381B4"
unencrypted_regex: "^(app_name|environment|sops)$"
# Development secrets use age
- path_regex: vault/dev\.ya?ml$
age: age1ql3z7hjy54pw3hyww5ayyfg7zqgvc7w3j2elw8zmrj2kg5sfn9aqmcac8p
unencrypted_regex: "^(app_name|environment|sops)$"
Why an allow-list rather than a deny-list? encrypted_regex protects
only the key names it happens to enumerate, and it anchors on the whole key.
The obvious pattern ^(password|secret|key|token)$ leaves
database_password, api_key, and ssl_private_key — the three fields in
the vault example above — entirely in plaintext, because none of them is
exactly password, secret, key, or token. The file looks encrypted,
sops reports success, and the secrets are committed in the clear. An
allow-list fails the other way: a newly added field is encrypted by default,
and the worst outcome of a mistake is an unnecessarily encrypted piece of
metadata.
CI MUST assert the outcome rather than the exit status. After encrypting a fixture that contains the documented key names, grep the ciphertext for each known plaintext value and fail if any is found.
Using SOPS with Ansible:
The community.sops.sops vars plugin loads encrypted files from group_vars/
and host_vars/ only, and only those whose extension is .sops.yaml,
.sops.yml, or .sops.json17. It is not a general interception hook for
vars_files.
# Encrypt into a path the vars plugin actually reads
sops -e vault/production.yml \
> inventories/production/group_vars/production/secrets.sops.yml
# Variables from group_vars/production/secrets.sops.yml are decrypted
# automatically for every host in the production group
- name: Deploy with SOPS-encrypted secrets
hosts: production
tasks:
- name: Configure database connection
ansible.builtin.template:
src: database.yml.j2
dest: /etc/app/database.yml
owner: root
group: app
mode: '0640'
no_log: true
To decrypt a file at an arbitrary path, load it explicitly:
- name: Load the SOPS-encrypted production secrets
community.sops.load_vars:
file: vault/production.enc.yml
expressions: ignore
no_log: true
Why not vars_files? vars_files bypasses the vars plugin entirely and
parses the file as ordinary YAML. The play still succeeds — but every value
is loaded as the literal ciphertext string, so database_password becomes
ENC[AES256_GCM,data:...] and that string is rendered straight into the
generated configuration file. The failure is silent at deploy time and only
surfaces when the application cannot authenticate.
Why set owner, group, and mode on a rendered secret? no_log
controls what Ansible prints; it has no effect on the filesystem. Without an
explicit mode, template creates the file under the remote user’s umask —
typically 0644 — so a decrypted credential lands world-readable and any
local account on the host can read it. Log suppression is not access control.
Enable SOPS vars plugin (ansible.cfg):
[defaults]
vars_plugins_enabled = host_group_vars,community.sops.sops
no_log Directive
Tasks handling sensitive data MUST use no_log:
- name: Set database password
ansible.builtin.user:
name: postgres
password: "{{ db_password | password_hash('sha512') }}"
no_log: true # Prevent password from appearing in logs
- name: Create API key
ansible.builtin.uri:
url: https://api.example.com/keys
method: POST
body_format: json
body:
name: "Production API Key"
secret: "{{ api_secret }}"
no_log: true
register: api_result
- name: Debug without sensitive data
ansible.builtin.debug:
msg: "API key created: {{ api_result.json.id }}"
Why no_log? Prevents sensitive data from being logged to console, log files, or CI/CD systems.
become Best Practices
Use become judiciously - only when needed:
# GOOD: become only when needed
- name: Configure application (non-root tasks)
hosts: webservers
become: false
tasks:
- name: Create app config in user home directory
ansible.builtin.copy:
src: app.conf
dest: ~/.app/config
- name: Install system package (requires root)
ansible.builtin.apt:
name: nginx
state: present
become: true # Escalate only for this task
# Configure become user and method
- name: Run application as specific user
ansible.builtin.command: /usr/local/bin/app-command
become: true
become_user: appuser
become_method: sudo
# ansible.cfg - Security hardening
# Comments MUST be on their own line: Ansible's INI parser keeps an inline
# "# ..." as part of the value, so `host_key_checking = True # comment`
# fails to parse as a boolean and silently evaluates to False.
[defaults]
# Verify SSH host keys
host_key_checking = True
[privilege_escalation]
# Don't become by default
become = False
# Use sudoers NOPASSWD
become_ask_pass = False
Why does this matter more than it looks? An inline hash comment on a
typed value is not a style problem. On an integer, Ansible raises
Invalid value provided for 'integer' and refuses to start — noisy, but
safe. On a boolean it is worse: the value True # Verify SSH host keys
is not truthy, so ansible-config dump reports
HOST_KEY_CHECKING = False. The configuration loads, the playbook runs, and
host-key verification is off — the exact opposite of what the line says it
does. Assert the effective configuration with
ansible-config dump --only-changed, not merely that the file parses.
File Permissions
Always set explicit file permissions:
# GOOD: Explicit permissions
- name: Deploy application configuration file
ansible.builtin.copy:
src: app.conf
dest: /etc/app/app.conf
owner: root
group: root
mode: '0644'
- name: Deploy secret key file
ansible.builtin.copy:
src: secret.key
dest: /etc/app/secret.key
owner: appuser
group: appuser
mode: '0600' # Only owner can read/write
- name: Create directory with sticky bit
ansible.builtin.file:
path: /var/tmp/shared
state: directory
mode: '1777' # rwxrwxrwt (sticky bit)
# BAD: No permissions specified (relies on umask)
- name: Deploy file
ansible.builtin.copy:
src: app.conf
dest: /etc/app/app.conf
SSH Security
Configure secure SSH connections:
# ansible.cfg
[defaults]
host_key_checking = True
private_key_file = ~/.ssh/ansible_ed25519
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s -o StrictHostKeyChecking=yes
pipelining = True
Why? Strict host key checking prevents man-in-the-middle attacks, and SSH key authentication is more secure than password authentication.
Testing
Molecule for Role Testing
Roles SHOULD be tested with Molecule3:
# Install Molecule with Docker driver
pipx install molecule==26.10.0 molecule-plugins[docker]==26.10.12
# Or install as part of ansible-dev-tools
pipx install ansible-dev-tools==26.10.0
# Initialize molecule in role
cd roles/nginx
molecule init scenario
# Run full test sequence
molecule test
# Individual steps
molecule create # Create test instance
molecule converge # Run playbook
molecule verify # Run verification tests
molecule destroy # Cleanup
Molecule Configuration (molecule/default/molecule.yml):
---
dependency:
name: galaxy
options:
requirements-file: requirements.yml
driver:
name: docker
platforms:
- name: ubuntu-26-04
image: "geerlingguy/docker-ubuntu2604-ansible@\
sha256:93834d97d0597ac0f4e8a2c499f805f36c0df3c09305ae8d7a08197368e70c2c"
pre_build_image: true
privileged: true
command: /lib/systemd/systemd
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
- name: ubuntu-24-04
image: "geerlingguy/docker-ubuntu2404-ansible@\
sha256:1c09ea777807564be091ae68836ae12e2cb02454505e912e3a19cb3fc181eaac"
pre_build_image: true
privileged: true
command: /lib/systemd/systemd
volumes:
- /sys/fs/cgroup:/sys/fs/cgroup:rw
cgroupns_mode: host
- name: debian-13
image: "geerlingguy/docker-debian13-ansible@\
sha256:979c0d5e12b0035fff1c46bd4dfd00d67cd82fa48035194f14f32e01c8c02372"
pre_build_image: true
privileged: true
command: /lib/systemd/systemd
- name: debian-12
image: "geerlingguy/docker-debian12-ansible@\
sha256:541b6cff5ec8c9fb0a926853400db0abab8b73c2912a2f4a4ef06137416605f1"
pre_build_image: true
privileged: true
command: /lib/systemd/systemd
provisioner:
name: ansible
config_options:
defaults:
callbacks_enabled: ansible.posix.profile_tasks
playbooks:
converge: converge.yml
verify: verify.yml
verifier:
name: ansible
The Molecule platform list MUST match the platforms block in
meta/main.yml. A role that advertises Debian 13 but only tests Debian 12
is making a claim its test suite never checks.
Converge Playbook (molecule/default/converge.yml):
---
- name: Converge
hosts: all
become: true
roles:
- role: nginx
vars:
nginx_port: 8080
nginx_worker_processes: 2
Verify Playbook (molecule/default/verify.yml):
---
- name: Verify
hosts: all
gather_facts: false
tasks:
- name: Verify nginx package is installed
ansible.builtin.package:
name: nginx
state: present
check_mode: true
register: nginx_package
failed_when: nginx_package.changed
- name: Verify nginx service is running
ansible.builtin.service:
name: nginx
state: started
check_mode: true
register: nginx_service
failed_when: nginx_service.changed
- name: Verify nginx responds to HTTP requests
ansible.builtin.uri:
url: http://localhost:8080
status_code: 200
Why Molecule? Molecule provides a standardized testing framework for Ansible roles, enabling test-driven development and CI integration across multiple platforms.
Testinfra for Verification
Molecule MAY use Testinfra18 for more complex verification:
# molecule/default/tests/test_default.py
import os
import testinfra.utils.ansible_runner
testinfra_hosts = testinfra.utils.ansible_runner.AnsibleRunner(
os.environ['MOLECULE_INVENTORY_FILE']
).get_hosts('all')
def test_nginx_is_installed(host):
nginx = host.package("nginx")
assert nginx.is_installed
def test_nginx_running_and_enabled(host):
nginx = host.service("nginx")
assert nginx.is_running
assert nginx.is_enabled
def test_nginx_config_file(host):
config = host.file("/etc/nginx/nginx.conf")
assert config.exists
assert config.user == "root"
assert config.group == "root"
assert config.mode == 0o644
def test_nginx_listening_on_port(host):
assert host.socket("tcp://0.0.0.0:8080").is_listening
Update molecule.yml to use Testinfra:
verifier:
name: testinfra
options:
v: 1
Performance Optimization
SSH Pipelining and Multiplexing
Enable SSH pipelining for faster execution:
# ansible.cfg
[ssh_connection]
pipelining = True
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
Why pipelining? Reduces the number of SSH operations by sending multiple commands over a single SSH connection, improving performance by 2-5x.
Parallelism with Forks
Increase parallelism with forks19:
# ansible.cfg
[defaults]
# Default is 5
forks = 20
# Or via command line
ansible-playbook -f 50 playbook.yml
Why more forks? Higher fork count enables Ansible to manage more hosts simultaneously, drastically reducing playbook execution time for large inventories.
Fact Caching
Enable fact caching to avoid gathering facts repeatedly:
# ansible.cfg
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .ansible_cache
# 24 hours, in seconds
fact_caching_timeout = 86400
Typed values MUST stand alone on their line. fact_caching_timeout is an
integer, and Ansible’s INI parser does not strip a trailing # 24 hours, so
ansible-config dump aborts with
Invalid value provided for 'integer': '86400 # 24 hours' and no playbook
runs until the comment is moved.
Alternative: Redis fact caching:
[defaults]
gathering = smart
fact_caching = community.general.redis
fact_caching_connection = localhost:6379:0
fact_caching_timeout = 86400
Async Tasks for Long-Running Operations
Long-running tasks SHOULD use async execution:
- name: Run long database backup
ansible.builtin.command: /usr/bin/backup-database.sh
async: 3600 # Maximum runtime (1 hour)
poll: 0 # Fire and forget
register: backup_job
# Continue with other tasks...
# A launch task with no loop registers a single job record, so poll it
# through backup_job.ansible_job_id
- name: Check backup completion
ansible.builtin.async_status:
jid: "{{ backup_job.ansible_job_id }}"
register: job_result
until: job_result.finished
retries: 360
delay: 10
# Several jobs per host: the launch task MUST loop, which is what gives the
# registered result a .results list to poll
- name: Deploy each component in parallel
ansible.builtin.command: "/usr/local/bin/deploy.sh {{ item }}"
async: 600
poll: 0
register: deploy_jobs
loop: "{{ deploy_components }}"
- name: Wait for all deployments to complete
ansible.builtin.async_status:
jid: "{{ item.ansible_job_id }}"
register: deploy_results
until: deploy_results.finished
retries: 60
delay: 10
loop: "{{ deploy_jobs.results }}"
loop_control:
label: "{{ item.item }}"
Why does the launch task need the loop? register stores one result per
host, not one per host in a list. Without a loop on the launch task,
deploy_jobs is a plain dictionary holding a single ansible_job_id, and
referencing deploy_jobs.results fails the play with
object of type 'dict' has no attribute 'results'. Ansible already runs the
launch task across hosts in parallel according to forks; the loop is only
needed when one host launches several jobs.
Mitogen (Optional Accelerator)
For maximum performance, MAY use Mitogen20:
# Install
pip install mitogen==0.3.54
# ansible.cfg
[defaults]
strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy
strategy = mitogen_linear
[ssh_connection]
pipelining = True
Why Mitogen? Mitogen reduces playbook execution time by 1.25x to 7x through optimized SSH connection pooling and reduced Python interpreter startup overhead.
Free Strategy for Independent Hosts
For independent host configurations, use the free strategy:
- name: Configure hosts independently
hosts: all
strategy: free # Don't wait for all hosts to complete each task
gather_facts: true
tasks:
- name: Install packages
ansible.builtin.apt:
name: nginx
state: present
Why free strategy? Allows faster hosts to proceed without waiting for slower ones, reducing overall playbook execution time.
Profiling and Optimization
Enable profiling to identify slow tasks:
# ansible.cfg
[defaults]
callbacks_enabled = profile_tasks, timer
# Run playbook with profiling
ansible-playbook playbooks/webservers.yml
# Example output:
# PLAY RECAP *********************************************************************
# web01 : ok=15 changed=3 unreachable=0 failed=0 skipped=2 rescued=0 ignored=0
#
# Sunday 07 December 2025 14:32:18 -0800 (0:00:02.345) 0:01:23.456 ******
# ===============================================================================
# Install nginx package ------------------------------------------------- 15.234s
# Deploy nginx configuration -------------------------------------------- 8.123s
# Update apt cache ------------------------------------------------------ 5.678s
CI/CD Integration
GitHub Actions
# .github/workflows/ansible-lint.yml
name: Ansible Lint
on:
push:
branches: [main]
pull_request:
branches: [main]
jobs:
lint:
name: Lint Ansible playbooks and roles
runs-on: ubuntu-latest
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install ansible-lint
run: |
pip install ansible-dev-tools==26.10.0
- name: Run ansible-lint
run: |
ansible-lint --profile=production
- name: Run yamllint
run: |
yamllint .
molecule:
name: Test roles with Molecule
runs-on: ubuntu-latest
strategy:
matrix:
role: [nginx, postgresql, common]
steps:
- name: Checkout code
uses: actions/checkout@3d3c42e5aac5ba805825da76410c181273ba90b1 # v7.0.1
- name: Set up Python
uses: actions/setup-python@5fda3b95a4ea91299a34e894583c3862153e4b97 # v7.0.0
with:
python-version: '3.12'
- name: Install Molecule and dependencies
run: |
pip install ansible-dev-tools==26.10.0 molecule-plugins[docker]==26.10.12
- name: Run Molecule tests
run: |
cd roles/${{ matrix.role }}
molecule test
See Also
- YAML Guide - YAML syntax and best practices
- Shell Guide - Shell scripting for Ansible modules
- Python Guide - Python for custom Ansible modules
- Testing Guide - General testing principles
- CI/CD Guide - Integrating Ansible in pipelines
References
Footnotes
-
ansible-lint - Best practices checker for Ansible. Identifies common mistakes and enforces style guidelines. GitHub ↩ ↩2
-
yamllint - Linter for YAML files. Ensures consistent YAML formatting across playbooks and roles. ↩ ↩2
-
Molecule - Testing framework for Ansible roles. Enables test-driven development and CI integration. GitHub ↩ ↩2
-
SOPS - Secrets OPerationS by Mozilla/CNCF. Editor of encrypted files supporting YAML, JSON, ENV, INI. Ansible SOPS Guide ↩ ↩2
-
Ansible Core 2.18 Porting Guide - Python 3.11+ required for control node, Python 3.8+ for managed nodes ↩
-
Organizing host and group variables - Ansible loads
group_vars/andhost_vars/from the directory containing the inventory file or the directory containing the playbook ↩ -
Ansible Dynamic Inventory - Automatically generated inventory from external sources (cloud providers, CMDBs) ↩
-
Fully Qualified Collection Names (FQCN) - Using complete module names like
ansible.builtin.copyinstead ofcopy. Required in Ansible 2.10+ ↩ -
ansible.builtin.service module -
started/stoppedare idempotent and run no command unless necessary;restartedalways bounces the service ↩ -
Ansible Role Structure - Standard directory layout for roles ↩
-
Variable Precedence - Order in which Ansible evaluates variables ↩
-
Debian releases and Ubuntu release cycle - Debian 11 (bullseye) LTS ended 2026-08-31; Ubuntu 20.04 standard support ended 2025-05-31 and continues under Ubuntu Pro ESM only ↩
-
PostgreSQL Versioning Policy - Each major is supported for five years; 16 is supported until 2028-11-09 and 18 is current. Always run the current minor release of the chosen major ↩
-
Role Argument Specs - Define and validate role input with argument specifications ↩
-
ansible-vault - Tool for encrypting sensitive data in Ansible. Supports file and variable encryption ↩
-
SOPS vs Ansible Vault Comparison - SOPS supports multiple KMS, encrypts only values (not keys), and works across ecosystems ↩
-
community.sops.sopsvars plugin - Loads encrypted files fromgroup_vars/andhost_vars/, restricted by default to.sops.yaml,.sops.yml, and.sops.jsonextensions ↩ -
Testinfra - Python library for testing infrastructure. Integrates with Molecule for comprehensive role testing ↩
-
Ansible Forks - Number of parallel processes Ansible uses. Higher values enable faster execution ↩
-
Mitogen for Ansible - Performance optimization that dramatically speeds up playbook execution through connection pooling. GitHub ↩