Skip to content

Best Practices

First PublishedByAtif Alam

The most important principle in Ansible: running a playbook twice should produce the same result as running it once. No unintended side effects, no duplicate changes.

Most built-in modules are idempotent by design:

# Idempotent — apt checks if nginx is already installed
- name: Install nginx
apt:
name: nginx
state: present
# Idempotent — service checks if nginx is already running
- name: Start nginx
service:
name: nginx
state: started

command and shell modules are not idempotent — they always run and always report changed:

# NOT idempotent — runs every time
- name: Create database
command: createdb myapp
# Fix: add a condition
- name: Check if database exists
command: psql -lqt
register: db_list
changed_when: false
- name: Create database if missing
command: createdb myapp
when: "'myapp' not in db_list.stdout"
  • creates — Skip if a file already exists:
- name: Run initial setup
command: /opt/myapp/setup.sh
args:
creates: /opt/myapp/.initialized
  • changed_when: false — For read-only commands that never change state.
  • state: present / state: absent — Most modules use state to be declarative.

Tags let you run only specific parts of a playbook:

tasks:
- name: Install packages
apt:
name:
- nginx
- curl
state: present
tags: [packages, setup]
- name: Deploy application
copy:
src: app/
dest: /opt/myapp/
tags: [deploy]
- name: Run database migrations
command: /opt/myapp/migrate.sh
tags: [deploy, database]
Terminal window
ansible-playbook site.yml --tags deploy # only deploy tasks
ansible-playbook site.yml --tags "deploy,database" # deploy + database
ansible-playbook site.yml --skip-tags packages # everything except packages
ansible-playbook site.yml --list-tags # show all available tags
  • always — Task runs no matter what tags are selected.
  • never — Task only runs if explicitly included with --tags.
- name: Debug info (only when asked)
debug:
msg: "{{ ansible_facts }}"
tags: [never, debug]
- name: Send notification (always runs)
slack:
msg: "Deploy complete"
tags: [always]

Encrypt sensitive data (passwords, API keys, certificates) that lives alongside your playbooks.

Terminal window
ansible-vault encrypt group_vars/production/secrets.yml
ansible-vault decrypt group_vars/production/secrets.yml
ansible-vault edit group_vars/production/secrets.yml # decrypt, edit, re-encrypt
ansible-vault view group_vars/production/secrets.yml # view without decrypting to disk
# group_vars/production/secrets.yml (encrypted)
db_password: "s3cur3_p4ssw0rd"
api_key: "ak_live_abc123"
ssl_private_key: |
-----BEGIN PRIVATE KEY-----
...
-----END PRIVATE KEY-----

Encrypt a single value instead of an entire file:

Terminal window
ansible-vault encrypt_string 's3cur3_p4ssw0rd' --name 'db_password'

Output (paste into your vars file):

db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
3565396...
Terminal window
ansible-playbook site.yml --ask-vault-pass # prompt for password
ansible-playbook site.yml --vault-password-file ~/.vault_pass # read from file

In CI/CD, store the vault password as a secret and pass it via file or environment variable:

Terminal window
echo "$VAULT_PASSWORD" > /tmp/.vault_pass
ansible-playbook site.yml --vault-password-file /tmp/.vault_pass
  • Separate secrets into their own file (secrets.yml) alongside non-sensitive vars — easier to manage.
  • Use vault IDs when you have different passwords for different environments:
Terminal window
ansible-vault encrypt --vault-id prod@prompt group_vars/production/secrets.yml
ansible-playbook site.yml --vault-id prod@~/.vault_pass_prod

Molecule is the standard testing framework for Ansible roles. It creates ephemeral instances (Docker, Vagrant, cloud), runs your role, and verifies the result.

Terminal window
pip install molecule molecule-docker
Terminal window
cd roles/nginx
molecule init scenario --driver-name docker

Creates:

molecule/
default/
molecule.yml # test config
converge.yml # playbook that applies the role
verify.yml # assertions
driver:
name: docker
platforms:
- name: instance
image: ubuntu:22.04
pre_build_image: true
provisioner:
name: ansible
verifier:
name: ansible
---
- name: Converge
hosts: all
become: true
roles:
- role: nginx
vars:
nginx_port: 8080
---
- name: Verify
hosts: all
tasks:
- name: Check nginx is installed
command: nginx -v
register: result
changed_when: false
failed_when: result.rc != 0
- name: Check nginx is running
service_facts:
- name: Assert nginx is active
assert:
that:
- "'nginx.service' in ansible_facts.services"
- "ansible_facts.services['nginx.service'].state == 'running'"
Terminal window
molecule test # full cycle: create → converge → verify → destroy
molecule converge # just run the role (keep the instance)
molecule verify # run verification only
molecule login # SSH into the test instance for debugging
molecule destroy # tear down
# GitHub Actions
- name: Run Molecule tests
run: |
pip install molecule molecule-docker
cd roles/nginx
molecule test
ansible/
inventory/
hosts.yml
group_vars/
all.yml
webservers.yml
host_vars/
web1.example.com.yml
playbooks/
site.yml
webservers.yml
dbservers.yml
roles/
nginx/
postgresql/
myapp/
files/
templates/
ansible.cfg
requirements.yml
ansible/
inventories/
dev/
hosts.yml
group_vars/
all.yml
all/
secrets.yml # vault encrypted
host_vars/
staging/
hosts.yml
group_vars/
production/
hosts.yml
group_vars/
playbooks/
site.yml
deploy.yml
rollback.yml
roles/
requirements.yml # Galaxy roles
internal/
nginx/
myapp/
ansible.cfg

Run per environment:

Terminal window
ansible-playbook -i inventories/production/hosts.yml playbooks/site.yml
ansible-playbook -i inventories/dev/hosts.yml playbooks/deploy.yml

Set project-wide defaults:

# ansible.cfg
[defaults]
inventory = ./inventory/hosts.yml
roles_path = ./roles
retry_files_enabled = false
stdout_callback = yaml # readable output format
timeout = 30
forks = 20 # parallel connections
[privilege_escalation]
become = true
become_method = sudo
become_user = root
[ssh_connection]
pipelining = true # faster execution
control_path_dir = /tmp/.ansible/cp

Place ansible.cfg in your project root. Ansible finds it automatically.

# GitHub Actions
name: Ansible Deploy
on:
push:
branches: [main]
paths: ["ansible/**"]
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- name: Install Ansible
run: pip install ansible
- name: Install Galaxy roles
run: ansible-galaxy install -r ansible/roles/requirements.yml
- name: Run playbook
env:
ANSIBLE_VAULT_PASSWORD: ${{ secrets.VAULT_PASSWORD }}
run: |
echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/.vault_pass
ansible-playbook \
-i ansible/inventories/production/hosts.yml \
ansible/playbooks/deploy.yml \
--vault-password-file /tmp/.vault_pass
rm /tmp/.vault_pass
Terminal window
ansible-playbook site.yml -v # task results
ansible-playbook site.yml -vv # task input parameters
ansible-playbook site.yml -vvv # SSH connection details
ansible-playbook site.yml -vvvv # full connection + plugin debugging

Start with -v, increase only if needed. -vvv is usually enough to diagnose SSH and permission issues.

Print variables and expressions mid-play:

- name: Show all facts
debug:
var: ansible_facts
- name: Show a specific variable
debug:
msg: "App port is {{ app_port }}, host is {{ ansible_host }}"
- name: Show registered output
debug:
var: deploy_result.stdout_lines
ErrorCauseFix
UnreachableSSH can’t connectCheck IP, port, SSH key, security groups, firewall
Permission deniedWrong SSH user or keySet ansible_user and ansible_ssh_private_key_file
Missing sudo passwordbecome: true but no passwordAdd --ask-become-pass or configure passwordless sudo
module not foundModule not installedInstall the collection: ansible-galaxy collection install ...
/usr/bin/python: not foundPython missing on targetSet ansible_python_interpreter: /usr/bin/python3 or install Python
Shared connection closedSSH timeout on slow tasksIncrease timeout in ansible.cfg or use async
variable undefinedTypo or missing varCheck spelling, check group_vars//host_vars/, use `
Terminal window
ansible all -m ping # test SSH to all hosts
ansible web1.example.com -m ping -vvv # detailed connection info
ansible all -m setup -a "filter=ansible_distribution" # test facts gathering

Run tasks one at a time, confirming each:

Terminal window
ansible-playbook site.yml --step

Ansible prompts before each task: (N)o/(y)es/(c)ontinue.

Resume from a specific task (e.g. after fixing an error):

Terminal window
ansible-playbook site.yml --start-at-task="Deploy application"
Terminal window
ansible-playbook site.yml --syntax-check # parse without running
ansible-lint site.yml # lint for best practices

ansible-lint catches style issues, deprecated syntax, and risky patterns.


By default, Ansible runs tasks on 5 hosts in parallel. Increase for larger inventories:

# ansible.cfg
[defaults]
forks = 20

Or per-run:

Terminal window
ansible-playbook site.yml -f 50

More forks = more parallel SSH connections = faster execution. Limit by your control node’s CPU/memory and target network capacity.

Reduces SSH operations by executing modules without copying them to a temp file:

# ansible.cfg
[ssh_connection]
pipelining = true

Requires requiretty to be disabled in /etc/sudoers on the targets (most modern systems don’t set it). Gives a significant speedup.

Ansible uses SSH ControlPersist by default — one SSH connection is reused for multiple tasks on the same host. Make sure it’s enabled:

# ansible.cfg
[ssh_connection]
ssh_args = -o ControlMaster=auto -o ControlPersist=60s
control_path_dir = /tmp/.ansible/cp

Fact gathering runs at the start of every play and can be slow with many hosts. Cache facts between runs:

# ansible.cfg
[defaults]
gathering = smart # only gather if cache is expired
fact_caching = jsonfile
fact_caching_connection = /tmp/ansible_fact_cache
fact_caching_timeout = 3600 # cache for 1 hour

Or use Redis for shared caching across CI jobs:

fact_caching = redis
fact_caching_connection = localhost:6379:0
fact_caching_timeout = 3600

Gather only what you need:

- name: Quick play
hosts: webservers
gather_facts: true
gather_subset:
- network
- hardware
# skip: distribution, virtual, ohai, facter

Or disable entirely with gather_facts: false if you don’t use any facts.

Mitogen is a third-party strategy plugin that dramatically speeds up Ansible by replacing SSH with a more efficient transport:

# ansible.cfg
[defaults]
strategy_plugins = /path/to/mitogen/ansible_mitogen/plugins/strategy
strategy = mitogen_linear

Reported speedups of 1.25x–7x depending on the workload. Drop-in replacement — no playbook changes needed.

SettingDefaultRecommended
forks520–50 (match your infra size)
pipeliningfalsetrue
gatheringimplicitsmart (with caching)
fact_cachingmemoryjsonfile or redis
ControlPersist60s60s (already good)
Strategylinearmitogen_linear (if available)

  • Idempotency first — Prefer declarative modules over command/shell. Use creates, changed_when, and conditional checks.
  • Tag tasks so you can run subsets of a playbook (--tags deploy, --skip-tags debug).
  • Encrypt secrets with Ansible Vault. Store the vault password in CI/CD secrets, never in code.
  • Test roles with Molecule — create, converge, verify, destroy. Run in CI.
  • Organize by environment — separate inventories with their own group_vars/ and secrets.
  • Set sane defaults in ansible.cfg (pipelining, forks, yaml output).
  • Debug with -v/-vv/-vvv, debug module, --step, and --start-at-task.
  • Tune performance with higher forks, pipelining, fact caching, and Mitogen.