Ansible Best Practices: Vault, Linting, Testing and CI
Keep secrets in Ansible Vault, roll out changes a few hosts at a time, speed up runs, lint with ansible-lint, test roles and run every change through CI.
A playbook that works on your laptop is the start. A team needs more: secrets that are not in plain text in Git, changes that cannot take down every server at once, runs that stay fast as the fleet grows, and a pipeline that checks every change before it reaches a server. This part adds each of those to the webfleet project.
Prerequisites
- The
~/webfleetproject with thebaselineandwebserverroles andsite.ymlfrom Part 8.
Keep Secrets in Ansible Vault
The site gets a status page at /status, protected with a user name and password. The password is a secret: it must not be in Git in plain text, but Ansible needs it to create the password file on the servers. Ansible Vault encrypts files or single values with AES-256, and Ansible decrypts them in memory during a run.
Add the status page to the role
The webserver role gets two new inputs. The user name has a default; the password has none and is required, so a forgotten password stops the run instead of creating an empty one. Add them to roles/webserver/defaults/main.yml:
---
# Team named on the home page
webserver_owner: Unknown team
# Port nginx listens on
webserver_port: 80
# User for the /status page; set webserver_status_password too
webserver_status_user: ops
and to roles/webserver/meta/argument_specs.yml. no_log: true keeps the value out of the output if the check fails:
---
argument_specs:
main:
short_description: Install nginx and publish the webfleet site
options:
webserver_owner:
type: str
default: Unknown team
description: Team named on the home page.
webserver_port:
type: int
default: 80
description: Port nginx listens on.
webserver_status_user:
type: str
default: ops
description: User for the /status page.
webserver_status_password:
type: str
required: true
no_log: true
description: Password for the /status page.
The password file is created with community.general.htpasswd, which needs the passlib Python library on the server. Install it together with nginx, and add the new task before Configure the site in roles/webserver/tasks/main.yml:
- name: Install nginx and passlib
ansible.builtin.apt:
name:
- nginx
- python3-passlib # needed by community.general.htpasswd
state: present
update_cache: true
cache_valid_time: 3600
tags: packages
# ...
- name: Create the status page user
community.general.htpasswd:
path: /etc/nginx/webfleet.htpasswd
name: '{{ webserver_status_user }}'
password: '{{ webserver_status_password }}'
owner: root
group: www-data
mode: '0640'
The module compares the password with the hash already in the file, so it only reports changed when the password really changes. Finally, add the location to roles/webserver/templates/webfleet.conf.j2, inside the server block. stub_status prints nginx's connection counters:
location = /status {
stub_status;
auth_basic "webfleet status";
auth_basic_user_file /etc/nginx/webfleet.htpasswd;
}
Encrypt the password
Vault needs a password of its own. Generate a long random one and keep it outside the project, readable only by you. In a team, store it in your password manager and share it through that, never through Git:
ANSIBLE_VAULT_PASSWORD_FILE tells every Ansible command where the password is, so you do not type it each time. Add the export line to your ~/.bashrc. Use --ask-vault-pass instead if you prefer to type it.
Group variables can be a directory instead of a file, and Ansible merges every file in it. Turn inventory/group_vars/web.yml into inventory/group_vars/web/vars.yml, and put the secret in inventory/group_vars/web/vault.yml next to it:
mkdir inventory/group_vars/web
mv inventory/group_vars/web.yml inventory/group_vars/web/vars.yml
---
webserver_owner: Platform team
webserver_port: 80
baseline_firewall_ports: # read by the baseline role
- '22'
- '80' # keep in sync with webserver_port
webserver_status_password: '{{ vault_webserver_status_password }}'
---
vault_webserver_status_password: lab-status-7Kp2
The convention is to prefix encrypted variables with vault_ and map them to normal names in the unencrypted file. That way grep webserver_status_password still finds where the value comes from, without decrypting anything. Now encrypt the file:
Change it later with ansible-vault edit, which decrypts into a temporary file, opens your editor, and encrypts again. ansible-vault rekey changes the vault password.
Run with the secret
Ansible decrypts the vault file in memory when it loads the inventory. To keep the output short, this run hides the tasks that reported ok, with the display_ok_hosts setting of the default output callback:
ANSIBLE_DISPLAY_OK_HOSTS=false ansible-playbook site.yml --limit web1
PLAY [Apply the baseline to every server] **************************************
PLAY [Set up the web servers] **************************************************
TASK [webserver : Install nginx and passlib] ***********************************
changed: [web1]
TASK [webserver : Create the status page user] *********************************
changed: [web1]
TASK [webserver : Configure the site] ******************************************
changed: [web1]
RUNNING HANDLER [webserver : Reload nginx] *************************************
changed: [web1]
PLAY RECAP *********************************************************************
web1 : ok=22 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
Basic authentication sends the user name and password with every request, only Base64-encoded. Over plain HTTP, anyone on the network path can read them. That is acceptable on this private lab network and nowhere else: on real servers, serve /status only over HTTPS, or limit it to your admin addresses with nginx's allow and deny and reach it through an SSH tunnel.
You can also encrypt single values instead of whole files, with ansible-vault encrypt_string, and paste the result into any YAML file:
ansible-vault encrypt_string --name db_password 'example-only'
db_password: !vault |
$ANSIBLE_VAULT;1.1;AES256
38383166623633333162376462353431363839376263336436373334356539366539383230663532
3961316134306163383230663861346661353638333266650a356136653332326665303561613866
64613736613061373937376532313331383466343038653666343236393237386664623436663864
3961666436613835310a323632613930653239643365643839343634633930383631613964396165
6530
Whole encrypted files are easier to review and to rekey; encrypted values keep the variable names readable. Pick one style per project.
Vault or a secret manager?
Vault keeps secrets next to the code, encrypted with one shared password. That fits small teams and a handful of secrets. It does not give you rotation, per-person access or an audit log of who read what, and anyone who has the vault password can read every secret in the file. When you need those, keep secrets in a secret manager and read them during the run with a lookup plugin:
# HashiCorp Vault (community.hashi_vault collection)
webserver_status_password: "{{ lookup('community.hashi_vault.vault_kv2_get', 'webfleet/status', engine_mount_point='secret').secret.password }}"
# AWS Secrets Manager (amazon.aws collection)
webserver_status_password: "{{ lookup('amazon.aws.secretsmanager_secret', 'webfleet/status-password') }}"
Either way, add no_log: true to tasks that print secrets in their results, such as a uri call with a token in its body. Starting with ansible-core 2.22, Ansible also masks known secret values, such as decrypted vault content, in its output.
Rolling Updates
site.yml runs each task on both web servers at once. For a change that could break the site, that means both servers break together. The serial keyword runs the whole play on a few hosts at a time: all tasks, the handlers and the post_tasks check, before the next batch starts. Add it to the web play in site.yml:
---
- name: Apply the baseline to every server
hosts: all
become: true
roles:
- role: baseline
- name: Set up the web servers
hosts: web
become: true
serial: 1 # one host at a time; also accepts a percentage or a list such as [1, "50%"]
max_fail_percentage: 0 # stop the rollout at the first failed host
roles:
- role: webserver
post_tasks:
- name: Fetch the home page from the control node
ansible.builtin.uri:
url: 'http://{{ ansible_host }}:{{ webserver_port }}/'
return_content: true
register: home_page
delegate_to: localhost
become: false
check_mode: false # a GET changes nothing, so it also runs with --check
- name: Check that the page names the host
ansible.builtin.assert:
that: ('<h1>Hello from ' ~ inventory_hostname ~ '</h1>') in home_page.content
fail_msg: '{{ ansible_host }} did not serve its own page'
ansible-playbook site.yml
PLAY [Apply the baseline to every server] **************************************
PLAY [Set up the web servers] **************************************************
TASK [Check that the page names the host] **************************************
PLAY [Set up the web servers] **************************************************
TASK [Check that the page names the host] **************************************
PLAY RECAP *********************************************************************
web1 : ok=20 changed=0 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
web2 : ok=22 changed=4 unreachable=0 failed=0 skipped=0 rescued=0 ignored=0
The web play now runs twice, once per host, and web2 is only touched after web1 passed its check. With max_fail_percentage: 0, a failure on web1 would have stopped the rollout before web2. serial takes the hosts in inventory order, so it does not know that web2 is the canary. To start with the canary, run it on its own first (ansible-playbook site.yml --limit web2), check the result, then run the playbook for everyone. Behind a load balancer you would also take each host out of the pool first and put it back after the check, with tasks delegated to the load balancer:
pre_tasks:
- name: Take the host out of the load balancer
ansible.builtin.uri:
url: 'https://lb.example.com/api/pools/web/members/{{ inventory_hostname }}/disable'
method: POST
delegate_to: localhost
become: false
Two more keywords help with ordered changes: run_once: true runs a task on one host of the batch (for example a database migration), and any_errors_fatal: true stops all hosts as soon as one fails.
Make Runs Faster
Most of a run's time is SSH round trips. Three settings matter most:
forks(default 5): how many hosts Ansible works on in parallel. Raise it to 20 or 50 for larger fleets, as far as your control node's CPU and memory allow.- Pipelining: sends the module through the existing SSH session's standard input instead of copying a file first, which saves round trips for every task. It needs
requirettyto be off insudoers, which is the default on current distributions. - Fact gathering: skip it with
gather_facts: falsewhere a play does not use facts, or limit it withgather_subset. For many runs per hour, cache facts between runs, so that a play gathers them only when the cache has none:
[defaults]
gathering = smart
fact_caching = jsonfile
fact_caching_connection = .cache/facts
fact_caching_timeout = 3600
Measure before you tune. Here is the same no-change run of site.yml, from cold SSH connections, without and with pipelining:
These are two small VMs on one machine, so take the numbers as an illustration; the saving grows with the number of tasks and with network latency. To see which tasks take the time, enable the profile_tasks callback from the ansible.posix collection for one run:
ANSIBLE_CALLBACKS_ENABLED=ansible.posix.profile_tasks ansible-playbook site.yml
The eight slowest tasks, from the end of the output:
webserver : Install nginx and passlib ---------------------------------- 40.48s
baseline : Install the firewall ---------------------------------------- 17.96s
baseline : Allow the listed ports --------------------------------------- 7.25s
webserver : Configure the site ------------------------------------------ 5.89s
webserver : Publish the home page --------------------------------------- 5.36s
webserver : Add security headers ---------------------------------------- 5.30s
webserver : Hide the nginx version in responses ------------------------- 5.29s
webserver : Start nginx and enable it at boot --------------------------- 5.21s
The two package tasks take the longest, which is typical: apt reads its package database on every run, and refreshes the package lists when they are older than cache_valid_time. Every other task costs about five seconds on these small VMs, mostly for starting Python on the host, so on a fleet like this the number of tasks matters more than any single one. Put the settings that help in ansible.cfg:
[defaults]
inventory = inventory/hosts.yml
roles_path = roles
collections_path = collections
forks = 20
[ssh_connection]
pipelining = True
Lint with ansible-lint
ansible-lint checks playbooks and roles against a few hundred rules for syntax, deprecated features, risky patterns and style. Install it next to Ansible:
Rules are grouped into profiles, from min (only what breaks) through basic, moderate, safety and shared to production, the strictest. Set the project's profile and the paths to skip in .ansible-lint at the top of the project:
---
profile: production
exclude_paths:
- collections/
- .cache/
Then run it from the project directory. With no arguments, it lints every playbook, role and variable file it finds:
ansible-lint
Passed: 0 failure(s), 0 warning(s) in 19 files processed of 21 encountered. Profile 'production' was required, and it passed.
The project passes. For comparison, here is what it reports for a short playbook written the way many older tutorials do. Save it as bad.yml in the project:
- hosts: web
become: yes
tasks:
- apt: name=nginx state=latest
- name: copy the config
copy:
src: nginx.conf
dest: /etc/nginx/nginx.conf
mode: 644
- name: restart nginx
shell: systemctl restart nginx
ansible-lint bad.yml
WARNING Listing 14 violation(s) that are fatal
name[play]: All plays should be named.
bad.yml:1:3
yaml[truthy]: Truthy value should be one of [false, true]
bad.yml:2
fqcn[action-core]: Use FQCN for builtin module actions (apt).
bad.yml:4:7 Use `ansible.builtin.apt` or `ansible.legacy.apt` instead.
name[missing]: All tasks should be named.
bad.yml:4 Task/Handler: apt name=nginx state=latest
no-free-form: Avoid using free-form when calling module actions. (apt)
bad.yml:4 apt
package-latest: Package installs should not use latest.
bad.yml:4 Task/Handler: apt name=nginx state=latest
name[casing]: All names should start with an uppercase letter.
bad.yml:5:13 Task/Handler: copy the config
risky-octal: `mode: 644` should have a string value with leading zero `mode: "01204"` or use symbolic mode.
bad.yml:5 Task/Handler: copy the config
fqcn[action-core]: Use FQCN for builtin module actions (copy).
bad.yml:6:7 Use `ansible.builtin.copy` or `ansible.legacy.copy` instead.
command-instead-of-module: systemctl used in place of systemd module
bad.yml:10 Task/Handler: restart nginx
command-instead-of-shell: Use shell only when shell functionality is required.
bad.yml:10 Task/Handler: restart nginx
name[casing]: All names should start with an uppercase letter.
bad.yml:10:13 Task/Handler: restart nginx
no-changed-when: Commands should not change things if nothing needs doing.
bad.yml:10 Task/Handler: restart nginx
fqcn[action-core]: Use FQCN for builtin module actions (shell).
bad.yml:11:7 Use `ansible.builtin.shell` or `ansible.legacy.shell` instead.
Read documentation for instructions on how to ignore specific rule violations.
# Rule Violation Summary
1 command-instead-of-module profile:basic tags:command-shell,idiom
1 command-instead-of-shell profile:basic tags:command-shell,idiom
1 no-free-form profile:basic tags:syntax,risk
1 name profile:basic tags:idiom
1 name profile:basic tags:idiom
1 yaml profile:basic tags:formatting,yaml
2 name profile:basic tags:idiom
1 package-latest profile:basic tags:idempotency
1 risky-octal profile:basic tags:formatting
1 no-changed-when profile:basic tags:command-shell,idempotency
3 fqcn profile:basic tags:formatting
Failed: 14 failure(s), 0 warning(s) in 1 files processed of 1 encountered. Profile 'production' was required, but 'min' profile passed.
Delete bad.yml again (rm bad.yml): the CI pipeline below lints the whole project, and this file would fail it. Each finding names the rule, the line, and usually the fix. The summary line says which profile the file meets: this one passes only min, far from the production profile the project requires. Run ansible-lint locally before you push, and in CI on every pull request. To skip a rule for one line, add a # noqa: <rule> comment with a reason next to it; to skip it everywhere, list it under skip_list in .ansible-lint. Treat both as exceptions you can explain in a review.
Test Roles with Molecule
ansible-lint reads code; it cannot tell you whether the role works. Molecule runs a role against a disposable test instance, usually a container, and runs it twice to prove it is idempotent. A scenario lives in the role, in molecule/default/, and current Molecule versions describe the test instances with a normal Ansible inventory:
# roles/webserver/molecule/default/molecule.yml
---
ansible:
cfg:
defaults:
roles_path: ${MOLECULE_PROJECT_DIRECTORY}/../
executor:
args:
ansible_playbook:
- --inventory=inventory/
dependency:
name: galaxy
options:
requirements-file: ${MOLECULE_SCENARIO_DIRECTORY}/requirements.yml
scenario:
test_sequence:
- dependency
- destroy
- create
- converge
- idempotence
- verify
- destroy
The instances are hosts in an inventory under molecule/default/inventory/, for example a Podman container with ansible_connection: containers.podman.podman. Put them in a group named web as well (for example, make the molecule group a child of web), because the role's home page template lists groups['web']. Choose the image with care: it must contain Python, and because the webserver role starts nginx through systemd, it must also run systemd as its first process, so use an image built for testing Ansible roles, or virtual machines. Next to the inventory go create.yml and destroy.yml, which start and remove the instances (the Molecule documentation has complete versions for Podman and Docker), and the two playbooks that do the testing:
# roles/webserver/molecule/default/converge.yml
---
- name: Converge
hosts: molecule
become: true
tasks:
- name: Apply the role under test
ansible.builtin.include_role:
name: webserver
vars:
webserver_owner: Test team
webserver_status_password: test-only
# roles/webserver/molecule/default/verify.yml
---
- name: Verify
hosts: molecule
gather_facts: false
tasks:
- name: Fetch the home page inside the instance
ansible.builtin.uri:
url: http://localhost/
return_content: true
register: home_page
- name: Check that the page shows the owner
ansible.builtin.assert:
that: "'Test team' in home_page.content"
molecule test runs the whole sequence, and the idempotence step fails if the second converge reports any change. This guide's lab has no container engine, so it shows the configuration without a run.
Run Every Change Through CI
Put the project in Git, and let a pipeline check every pull request and run the playbook from main. This GitHub Actions workflow lints on every change and deploys on pushes to main:
---
name: ansible
on:
pull_request:
push:
branches: [main]
permissions:
contents: read
jobs:
lint:
runs-on: ubuntu-24.04
steps:
- uses: actions/checkout@v7
- uses: actions/setup-python@v7
with:
python-version: '3.13'
- name: Install Ansible and ansible-lint
run: pip install ansible-core==2.21.5 ansible-lint==26.10.1
- name: Install collections
run: ansible-galaxy collection install -r requirements.yml
- name: Lint
run: ansible-lint
- name: Check the playbook syntax
run: ansible-playbook site.yml --syntax-check
deploy:
needs: lint
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
# a runner that can reach the servers over SSH
runs-on: [self-hosted, webfleet]
environment: production
concurrency: production
steps:
- uses: actions/checkout@v7
- name: Install collections
run: ansible-galaxy collection install -r requirements.yml
- name: Write the SSH key, known hosts and vault password
env:
SSH_KEY: ${{ secrets.DEPLOY_SSH_KEY }}
KNOWN_HOSTS: ${{ secrets.DEPLOY_KNOWN_HOSTS }}
VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
run: |
install -m 700 -d ~/.ssh
printf '%s\n' "$SSH_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
printf '%s\n' "$KNOWN_HOSTS" > ~/.ssh/known_hosts
printf '%s' "$VAULT_PASSWORD" > "$RUNNER_TEMP/vault-pass"
chmod 600 "$RUNNER_TEMP/vault-pass"
- name: Deploy
env:
ANSIBLE_VAULT_PASSWORD_FILE: ${{ runner.temp }}/vault-pass
run: ansible-playbook site.yml --diff
- name: Remove the credentials
if: always()
run: rm -f ~/.ssh/id_ed25519 "$RUNNER_TEMP/vault-pass"
Notes on the choices:
- The lint job needs no secrets, so it also runs safely on pull requests from forks.
- The deploy job runs on a self-hosted runner because GitHub's hosted runners cannot reach private servers. The
productionenvironment lets you require an approval before it runs, andconcurrencystops two deployments from overlapping. - The host keys come from a secret too, so the job never has to trust a key on first use.
- The self-hosted runner has Ansible installed already. On a hosted runner, add the same install step as in the lint job.
The pipeline works from a Git repository, so put the project into one if it is not there yet, and keep installed collections and caches out of it with a .gitignore:
collections/
.cache/
git init -b main
Initialized empty Git repository in /home/dev/webfleet/.git/
Then commit and push as usual. actionlint checks the workflow file itself:
actionlint .github/workflows/ansible.yml
.github/workflows/ansible.yml:33:28: label "webfleet" is unknown. available labels are "windows-latest", "windows-latest-8-cores", "windows-2025", "windows-2025-vs2026", "windows-2022", "windows-11-arm", "ubuntu-slim", "ubuntu-latest", "ubuntu-latest-4-cores", "ubuntu-latest-8-cores", "ubuntu-latest-16-cores", "ubuntu-24.04", "ubuntu-24.04-arm", "ubuntu-22.04", "ubuntu-22.04-arm", "macos-latest", "macos-latest-xlarge", "macos-latest-large", "macos-26-intel", "macos-26-xlarge", "macos-26-large", "macos-26", "macos-15-intel", "macos-15-xlarge", "macos-15-large", "macos-15", "macos-14-xlarge", "macos-14-large", "macos-14", "self-hosted", "x64", "arm", "arm64", "linux", "macos", "windows". if it is a custom label for self-hosted runner, set list of labels in actionlint.yaml config file [runner-label]
|
33 | runs-on: [self-hosted, webfleet]
| ^~~~~~~~~
It does not know the webfleet label of the self-hosted runner. Declare custom runner labels in .github/actionlint.yaml:
---
self-hosted-runner:
labels:
- webfleet
actionlint .github/workflows/ansible.yml; echo "exit code: $?"
exit code: 0
No output and exit code 0 mean no problems. Add actionlint to the lint job if you change workflows often.
To catch drift, changes someone made by hand on a server, schedule a second workflow that runs ansible-playbook site.yml --check --diff every night and alerts when the recap shows changed for any host.
Common Mistakes
- Secrets in plain text in Git, even "temporarily". Encrypt them before the first commit; Git keeps history.
- The vault password in the repository, or in
ansible.cfgthat points at a file in the repository. - Changing all hosts at once. Use
serialwith a health check inpost_tasksfor anything that restarts a service. - Running playbooks from laptops for production. Run them from CI or an automation platform, so there is one place, one version of the code, and a log.
- Silencing lint rules without a reason. Every
noqashould say why.
Summary
- Encrypt secrets with Ansible Vault, prefix them with
vault_, keep the vault password outside the repository, and move to a secret manager when you need rotation and auditing. serialandmax_fail_percentageturn a run into a rolling update with a check after each batch.- Raise
forks, enable pipelining and skip unneeded facts; measure withprofile_tasks. ansible-lintwith theproductionprofile catches deprecated and risky code; Molecule tests that a role converges and is idempotent.- A CI pipeline lints every change and deploys from
main, with credentials only in the deploy job.
In Part 10, the last part, you write a dynamic inventory, a custom module and a filter plugin, build an execution environment, and see where Ansible Automation Platform, AWX and Terraform fit.
Keep Learning
- HashiCorp Vault secrets management checklist
- Secrets management guide
- CI/CD Pipeline Setup Checklist
- Your Automation Platform Is a Credential Honeypot, on what an Ansible control plane holds
Tags
Found an issue?