crefft Source 2026.10.4

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

TaskToolCommand
Installpipxpipx install --include-deps ansible-core==2.21.5
Install dev toolspipxpipx install ansible-dev-tools==26.10.0
Lintansible-lint1ansible-lint
YAML lintyamllint2yamllint .
Test rolesMolecule3molecule test
Syntax checkansible-playbookansible-playbook --syntax-check playbook.yml
Dry runansible-playbookansible-playbook --check playbook.yml
List tasksansible-playbookansible-playbook --list-tasks playbook.yml
Vault encryptansible-vaultansible-vault encrypt vars/secrets.yml
SOPS encryptsops4sops -e vars/secrets.yml
Galaxy installansible-galaxyansible-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:

PluginCollectionPinned version
amazon.aws.aws_ec2amazon.aws11.4.0
azure.azcollection.azure_rmazure.azcollection4.0.0
google.cloud.gcp_computegoogle.cloud1.14.0
openstack.cloud.openstackopenstack.cloud2.6.0
community.vmware.vmware_vm_inventorycommunity.vmware6.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 name field
  • 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 installation
  • config - Configuration file deployment
  • service - Service management
  • verify - Verification/testing tasks
  • never - 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 together
  • rescue - 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.

PlatformStatus on 2026-09-08Support endsInclude?
Debian 13 (trixie)Current stable2028-08-09 (LTS 2030-06-30)Yes, default
Debian 12 (bookworm)Oldstable, LTS2028-06-30Yes, compatibility
Debian 11 (bullseye)LTS ended 2026-08-31ExpiredNo
Ubuntu 26.04 (resolute)Current LTS2031-04Yes, default
Ubuntu 24.04 (noble)LTS2029-04Yes
Ubuntu 22.04 (jammy)LTS2027-04Yes, compatibility
Ubuntu 20.04 (focal)Standard support ended 2025-05-31ESM onlyNo

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

References

Footnotes

  1. ansible-lint - Best practices checker for Ansible. Identifies common mistakes and enforces style guidelines. GitHub ↩ ↩2

  2. yamllint - Linter for YAML files. Ensures consistent YAML formatting across playbooks and roles. ↩ ↩2

  3. Molecule - Testing framework for Ansible roles. Enables test-driven development and CI integration. GitHub ↩ ↩2

  4. SOPS - Secrets OPerationS by Mozilla/CNCF. Editor of encrypted files supporting YAML, JSON, ENV, INI. Ansible SOPS Guide ↩ ↩2

  5. Ansible Core 2.18 Porting Guide - Python 3.11+ required for control node, Python 3.8+ for managed nodes ↩

  6. Organizing host and group variables - Ansible loads group_vars/ and host_vars/ from the directory containing the inventory file or the directory containing the playbook ↩

  7. Ansible Dynamic Inventory - Automatically generated inventory from external sources (cloud providers, CMDBs) ↩

  8. Fully Qualified Collection Names (FQCN) - Using complete module names like ansible.builtin.copy instead of copy. Required in Ansible 2.10+ ↩

  9. ansible.builtin.service module - started/stopped are idempotent and run no command unless necessary; restarted always bounces the service ↩

  10. Ansible Role Structure - Standard directory layout for roles ↩

  11. Variable Precedence - Order in which Ansible evaluates variables ↩

  12. 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 ↩

  13. 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 ↩

  14. Role Argument Specs - Define and validate role input with argument specifications ↩

  15. ansible-vault - Tool for encrypting sensitive data in Ansible. Supports file and variable encryption ↩

  16. SOPS vs Ansible Vault Comparison - SOPS supports multiple KMS, encrypts only values (not keys), and works across ecosystems ↩

  17. community.sops.sops vars plugin - Loads encrypted files from group_vars/ and host_vars/, restricted by default to .sops.yaml, .sops.yml, and .sops.json extensions ↩

  18. Testinfra - Python library for testing infrastructure. Integrates with Molecule for comprehensive role testing ↩

  19. Ansible Forks - Number of parallel processes Ansible uses. Higher values enable faster execution ↩

  20. Mitogen for Ansible - Performance optimization that dramatically speeds up playbook execution through connection pooling. GitHub ↩