Learn how to combine Ansible Vault with HashiCorp Vault using the community.hashi_vault collection, AppRole authentication, and least-privilege policies for production-grade secrets management in playbooks.
Prerequisites
- A running HashiCorp Vault server (or ability to install one)
- Ansible control node with ansible-core 2.14 or later
- sudo or root access on target hosts
- Basic familiarity with Ansible playbooks and YAML
- Network connectivity between the Ansible control node and Vault API on port 8200
What this solves
Ansible Vault encrypts static files at rest, but it does not rotate secrets, audit access, or issue short-lived credentials. HashiCorp Vault solves dynamic secrets and centralized access control, while Ansible Vault remains useful as an encrypted fallback for bootstrap tokens.
This tutorial configures the community.hashi_vault collection with AppRole authentication, retrieves secrets dynamically in playbooks with the hashi_vault lookup plugin, and implements least-privilege policies so automation only reads what it needs.
Overview: Ansible Vault vs HashiCorp Vault architecture
Ansible Vault is a file encryption feature built into ansible-core. It encrypts YAML files, group_vars, or single strings using a passphrase or a key file. There is no server, no audit log, and no dynamic secret generation. It is a good fit for encrypting a small number of static values checked into version control.
HashiCorp Vault is a full secrets management server with authentication backends, secrets engines, audit logging, leases, and revocation. It can generate short-lived database credentials, cloud IAM tokens, and PKI certificates on demand.
In a production Ansible setup, the pattern is: Vault holds live secrets and issues them dynamically through the hashi_vault lookup plugin at playbook runtime. Ansible Vault only encrypts the small bootstrap credential (an AppRole secret_id or a wrapped token) needed to authenticate to HashiCorp Vault in the first place.
Step-by-step configuration
Install HashiCorp Vault server prerequisites
Vault needs its own repository on both Debian-based and RHEL-based systems. Add the HashiCorp repo before installing.
sudo apt update
sudo apt install -y gnupg software-properties-common curl
curl -fsSL https://apt.releases.hashicorp.com/gpg | sudo gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" | sudo tee /etc/apt/sources.list.d/hashicorp.list
sudo apt update
sudo apt install -y vaultsudo dnf install -y dnf-plugins-core
sudo dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
sudo dnf install -y vaultConfigure Vault storage and listener
For production, Vault should run with a real storage backend (Raft integrated storage is common) and TLS enabled. This minimal config uses Raft for a single-node dev-to-prod path.
storage "raft" {
path = "/opt/vault/data"
node_id = "vault-node-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/etc/vault.d/tls/vault-cert.pem"
tls_key_file = "/etc/vault.d/tls/vault-key.pem"
}
api_addr = "https://vault.internal.example.com:8200"
cluster_addr = "https://vault.internal.example.com:8201"
ui = trueCreate the data directory with correct ownership before starting the service. Vault runs as its own system user, not root.
sudo mkdir -p /opt/vault/data /etc/vault.d/tls
sudo chown -R vault:vault /opt/vault/data
sudo chmod 750 /opt/vault/dataStart Vault and initialize it
Start the service, then initialize and unseal it. Store the unseal keys and root token somewhere secure, not in shell history.
sudo systemctl enable --now vault
export VAULT_ADDR='https://vault.internal.example.com:8200'
vault operator init -key-shares=5 -key-threshold=3For production clusters, look at configuring Vault auto-unseal with AWS KMS to avoid manual unseal operations after every restart.
Enable the KV secrets engine
Enable KV version 2 at a dedicated path for Ansible-managed secrets. Version 2 gives you versioning and soft-delete.
vault login
vault secrets enable -path=ansible kv-v2
vault kv put ansible/app/db username="appuser" password="Xk9$mQ2vLp7@wRz4"
vault kv get ansible/app/dbCreate an AppRole for Ansible automation
AppRole authentication is designed for machine-to-machine auth, which fits Ansible control nodes better than human login methods.
vault auth enable approle
vault write auth/approle/role/ansible-automation \
token_policies="ansible-readonly" \
token_ttl=15m \
token_max_ttl=1h \
secret_id_ttl=90d \
secret_id_num_uses=0Retrieve the role_id and generate a secret_id. The role_id is not secret by itself, but the secret_id must be protected.
vault read auth/approle/role/ansible-automation/role-id
vault write -f auth/approle/role/ansible-automation/secret-idWrite a least-privilege policy
Scope the policy to only the paths automation needs. Avoid wildcard-everything policies attached to automation roles.
path "ansible/data/app/*" {
capabilities = ["read"]
}
path "ansible/metadata/app/*" {
capabilities = ["list"]
}vault policy write ansible-readonly ansible-readonly.hclThis mirrors the policy scoping approach used in configuring advanced Consul ACL policies for production security hardening: grant read-only access to the exact secret paths a workload touches, nothing broader.
Install the community.hashi_vault Ansible collection
Install Ansible itself if it is not already present, then add the collection and its Python dependency.
sudo apt update
sudo apt install -y python3-pip pipx
pipx install --include-deps ansible
pipx runpip ansible install hvac requestssudo dnf install -y python3-pip
pip3 install --user ansible hvac requestsansible-galaxy collection install community.hashi_vault
ansible-galaxy collection list community.hashi_vaultStore the AppRole secret_id with Ansible Vault as a fallback
The role_id and secret_id are the only credentials Ansible needs on disk. Encrypt the secret_id with Ansible Vault so it never sits in plaintext in your repository.
ansible-vault create group_vars/all/vault_approle.ymlvault_role_id: "9a8f3c2e-1234-4d5e-9f6a-abc123def456"
vault_secret_id: "b7e6d5c4-9876-4321-8888-fedcba098765"Restrict who can decrypt this file by using a dedicated vault password file with strict permissions, not a password shared in chat.
touch ~/.ansible_vault_pass
chmod 600 ~/.ansible_vault_pass
echo "a-strong-unique-vault-password" > ~/.ansible_vault_passConfigure ansible.cfg to point to this password file so playbook runs can decrypt automatically in CI.
[defaults]
vault_password_file = ~/.ansible_vault_pass
collections_path = ~/.ansible/collectionsRetrieve secrets dynamically with the hashi_vault lookup plugin
Reference the encrypted role_id and secret_id, then use the lookup plugin to pull live secrets from Vault at playbook run time instead of storing the final values anywhere.
---
- name: Deploy application with dynamic Vault secrets
hosts: app_servers
become: true
vars:
vault_addr: "https://vault.internal.example.com:8200"
tasks:
- name: Fetch database credentials from Vault
ansible.builtin.set_fact:
db_creds: "{{ lookup('community.hashi_vault.hashi_vault',
'secret=ansible/data/app/db:data',
url=vault_addr,
auth_method='approle',
role_id=vault_role_id,
secret_id=vault_secret_id) }}"
no_log: true
- name: Write application environment file
ansible.builtin.template:
src: templates/app.env.j2
dest: /etc/myapp/app.env
owner: myapp
group: myapp
mode: '0640'
no_log: trueThe no_log: true directive stops decrypted secret values from appearing in Ansible output or logs. Always set it on tasks that handle secret material.
Alternative: use the hashi_vault lookup inline in variable files
For secrets referenced across many roles, define the lookup once in group_vars rather than repeating set_fact tasks.
db_password: "{{ lookup('community.hashi_vault.hashi_vault',
'secret=ansible/data/app/db:data.password',
url='https://vault.internal.example.com:8200',
auth_method='approle',
role_id=vault_role_id,
secret_id=vault_secret_id) }}"Restrict token TTL and rotate the AppRole secret_id
Short-lived tokens limit the blast radius of a leaked credential. Rotate the secret_id on a schedule instead of leaving it valid indefinitely.
vault write -f auth/approle/role/ansible-automation/secret-id
vault list auth/approle/role/ansible-automation/secret-id-accessorRevoke an old secret_id accessor once the new one is deployed to your Ansible Vault file.
vault write auth/approle/role/ansible-automation/secret-id-accessor/destroy \
secret_id_accessor="a1b2c3d4-role-accessor-id"Verify your setup
Confirm Vault is unsealed, the AppRole authenticates correctly, and Ansible can retrieve a secret before running it against production hosts.
vault status
vault write auth/approle/login role_id="9a8f3c2e-1234-4d5e-9f6a-abc123def456" secret_id="b7e6d5c4-9876-4321-8888-fedcba098765"
ansible-playbook deploy-app.yml --check --diff --ask-vault-passRun a minimal test playbook that only performs a lookup and prints a masked confirmation, to validate connectivity without exposing secret values.
---
- name: Test Vault connectivity
hosts: localhost
tasks:
- name: Confirm secret retrieval
ansible.builtin.set_fact:
secret_retrieved: "{{ lookup('community.hashi_vault.hashi_vault',
'secret=ansible/data/app/db:data',
url='https://vault.internal.example.com:8200',
auth_method='approle',
role_id=vault_role_id,
secret_id=vault_secret_id) is defined }}"
no_log: true
- name: Report result
ansible.builtin.debug:
msg: "Vault lookup succeeded: {{ secret_retrieved }}"Common issues
| Symptom | Cause | Fix |
|---|---|---|
| permission denied on secret path | AppRole policy does not include the exact KV path | Check vault policy read ansible-readonly and add the missing path with capabilities = ["read"] |
| invalid role or secret ID error during login | secret_id expired or was single-use and already consumed | Generate a new secret_id with vault write -f auth/approle/role/ansible-automation/secret-id and update the Ansible Vault file |
| hashi_vault lookup plugin not found | Collection not installed or wrong collections_path in ansible.cfg | Run ansible-galaxy collection list community.hashi_vault and confirm the path matches ansible.cfg |
| connection refused to Vault API | Firewall blocking port 8200 or wrong VAULT_ADDR | Allow the specific source IP range on port 8200 with a scoped firewall rule, do not disable the firewall |
| secrets appear in Ansible output logs | Missing no_log directive on tasks handling secret values | Add no_log: true to every task that fetches or templates secret data |
| Vault sealed after server reboot | Manual unseal required, no auto-unseal configured | Configure auto-unseal with a cloud KMS or run vault operator unseal with quorum key shares |
Next steps
- Configure Ansible Vault for secret management and encryption with playbook automation
- Set up Vault as a PKI certificate authority with SSL automation and intermediate CA
- Integrate AWX with HashiCorp Vault for dynamic secrets management and secure automation workflows
- Configure Vault dynamic secrets for databases with PostgreSQL and MySQL integration
- Implement Ansible testing with Molecule and TestInfra for infrastructure automation validation
- Automate HashiCorp Vault AppRole secret rotation with systemd timers
Running this in production?
Automated install script
Run this to automate the entire setup
#!/usr/bin/env bash
set -euo pipefail
# ---------------------------------------------------------------------------
# Configure HashiCorp Vault server + Ansible Vault integration bootstrap
# Usage: ./install-vault-ansible.sh <vault-fqdn> [ansible-user]
# ---------------------------------------------------------------------------
RED='\033[0;31m'
GREEN='\033[0;32m'
YELLOW='\033[1;33m'
NC='\033[0m'
log() { echo -e "${GREEN}[$1/$2]${NC} $3"; }
warn() { echo -e "${YELLOW}WARN:${NC} $1"; }
err() { echo -e "${RED}ERROR:${NC} $1" >&2; }
usage() {
echo "Usage: $0 <vault-fqdn> [ansible-user]"
echo " vault-fqdn FQDN Vault will listen/advertise on (e.g. vault.internal.example.com)"
echo " ansible-user Local user that runs Ansible playbooks (default: ansible)"
exit 1
}
[ $# -lt 1 ] && usage
VAULT_FQDN="$1"
ANSIBLE_USER="${2:-ansible}"
TOTAL_STEPS=9
# ---------------------------------------------------------------------------
# Rollback on failure
# ---------------------------------------------------------------------------
cleanup() {
local exit_code=$?
if [ $exit_code -ne 0 ]; then
err "Installation failed at step. Rolling back partial changes..."
systemctl stop vault 2>/dev/null || true
rm -rf /etc/vault.d/vault.hcl.new 2>/dev/null || true
fi
exit $exit_code
}
trap cleanup ERR
# ---------------------------------------------------------------------------
# Root check
# ---------------------------------------------------------------------------
if [ "$(id -u)" -ne 0 ]; then
err "This script must be run as root or with sudo."
exit 1
fi
# ---------------------------------------------------------------------------
# [1/N] Detect distro
# ---------------------------------------------------------------------------
log 1 $TOTAL_STEPS "Detecting distribution..."
if [ -f /etc/os-release ]; then
. /etc/os-release
case "$ID" in
ubuntu|debian) PKG_MGR="apt"; PKG_INSTALL="apt install -y" ;;
almalinux|rocky|centos|rhel|ol|fedora) PKG_MGR="dnf"; PKG_INSTALL="dnf install -y" ;;
amzn) PKG_MGR="yum"; PKG_INSTALL="yum install -y" ;;
*) err "Unsupported distro: $ID"; exit 1 ;;
esac
else
err "/etc/os-release not found. Cannot detect distro."
exit 1
fi
echo "Detected: $ID ($PKG_MGR)"
# ---------------------------------------------------------------------------
# [2/N] Prerequisite tools
# ---------------------------------------------------------------------------
log 2 $TOTAL_STEPS "Installing prerequisite tools..."
if [ "$PKG_MGR" = "apt" ]; then
apt update
$PKG_INSTALL gnupg software-properties-common curl python3-pip
else
$PKG_INSTALL curl python3-pip dnf-plugins-core || true
fi
command -v curl >/dev/null || { err "curl is required but missing."; exit 1; }
# ---------------------------------------------------------------------------
# [3/N] Add HashiCorp repo and install Vault
# ---------------------------------------------------------------------------
log 3 $TOTAL_STEPS "Adding HashiCorp repository and installing Vault..."
if [ "$PKG_MGR" = "apt" ]; then
curl -fsSL https://apt.releases.hashicorp.com/gpg | gpg --dearmor -o /usr/share/keyrings/hashicorp-archive-keyring.gpg
echo "deb [signed-by=/usr/share/keyrings/hashicorp-archive-keyring.gpg] https://apt.releases.hashicorp.com $(lsb_release -cs) main" \
> /etc/apt/sources.list.d/hashicorp.list
apt update
$PKG_INSTALL vault
else
# RHEL family: dnf and yum both support config-manager repo add
if [ "$PKG_MGR" = "dnf" ]; then
dnf config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo
else
yum-config-manager --add-repo https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo 2>/dev/null || \
curl -fsSL https://rpm.releases.hashicorp.com/RHEL/hashicorp.repo -o /etc/yum.repos.d/hashicorp.repo
fi
$PKG_INSTALL vault
fi
command -v vault >/dev/null || { err "Vault binary not found after install."; exit 1; }
# ---------------------------------------------------------------------------
# [4/N] Install Ansible + hashi_vault collection
# ---------------------------------------------------------------------------
log 4 $TOTAL_STEPS "Installing Ansible and community.hashi_vault collection..."
if [ "$PKG_MGR" = "apt" ]; then
$PKG_INSTALL ansible
else
$PKG_INSTALL ansible || $PKG_INSTALL epel-release ansible
fi
pip3 install --quiet hvac
ansible-galaxy collection install community.hashi_vault --force
# ---------------------------------------------------------------------------
# [5/N] Create vault system user/dirs
# ---------------------------------------------------------------------------
log 5 $TOTAL_STEPS "Creating data directories with correct ownership..."
id vault >/dev/null 2>&1 || useradd --system --home /etc/vault.d --shell /sbin/nologin vault
mkdir -p /opt/vault/data /etc/vault.d/tls
chown -R vault:vault /opt/vault/data /etc/vault.d
chmod 750 /opt/vault/data
chmod 755 /etc/vault.d
chmod 700 /etc/vault.d/tls
# ---------------------------------------------------------------------------
# [6/N] Write Vault configuration
# ---------------------------------------------------------------------------
log 6 $TOTAL_STEPS "Writing Vault server configuration..."
cat > /etc/vault.d/vault.hcl <<EOF
storage "raft" {
path = "/opt/vault/data"
node_id = "vault-node-1"
}
listener "tcp" {
address = "0.0.0.0:8200"
tls_cert_file = "/etc/vault.d/tls/vault-cert.pem"
tls_key_file = "/etc/vault.d/tls/vault-key.pem"
}
api_addr = "https://${VAULT_FQDN}:8200"
cluster_addr = "https://${VAULT_FQDN}:8201"
ui = true
EOF
chown vault:vault /etc/vault.d/vault.hcl
chmod 640 /etc/vault.d/vault.hcl
if [ ! -f /etc/vault.d/tls/vault-cert.pem ] || [ ! -f /etc/vault.d/tls/vault-key.pem ]; then
warn "No TLS certificate found at /etc/vault.d/tls/. Generating a self-signed cert for bootstrap."
warn "Replace with a CA-issued certificate before production use."
openssl req -x509 -newkey rsa:4096 -sha256 -days 365 -nodes \
-keyout /etc/vault.d/tls/vault-key.pem \
-out /etc/vault.d/tls/vault-cert.pem \
-subj "/CN=${VAULT_FQDN}" \
-addext "subjectAltName=DNS:${VAULT_FQDN}" >/dev/null 2>&1
chown vault:vault /etc/vault.d/tls/vault-cert.pem /etc/vault.d/tls/vault-key.pem
chmod 640 /etc/vault.d/tls/vault-key.pem
chmod 644 /etc/vault.d/tls/vault-cert.pem
fi
# ---------------------------------------------------------------------------
# [7/N] Configure firewall
# ---------------------------------------------------------------------------
log 7 $TOTAL_STEPS "Configuring firewall for Vault port 8200/tcp..."
if command -v ufw >/dev/null 2>&1 && ufw status | grep -q "Status: active"; then
ufw allow 8200/tcp
elif command -v firewall-cmd >/dev/null 2>&1 && systemctl is-active --quiet firewalld; then
firewall-cmd --permanent --add-port=8200/tcp
firewall-cmd --reload
else
warn "No active firewall manager detected (ufw/firewalld). Skipping firewall rule."
fi
# ---------------------------------------------------------------------------
# [8/N] Start and enable Vault
# ---------------------------------------------------------------------------
log 8 $TOTAL_STEPS "Enabling and starting Vault service..."
systemctl enable --now vault
sleep 3
if ! systemctl is-active --quiet vault; then
err "Vault service failed to start. Check: journalctl -u vault"
exit 1
fi
# ---------------------------------------------------------------------------
# [9/N] Prepare Ansible Vault bootstrap directory
# ---------------------------------------------------------------------------
log 9 $TOTAL_STEPS "Setting up Ansible Vault bootstrap directory for user '${ANSIBLE_USER}'..."
if ! id "$ANSIBLE_USER" >/dev/null 2>&1; then
warn "User '${ANSIBLE_USER}' does not exist. Creating it."
useradd --create-home --shell /bin/bash "$ANSIBLE_USER"
fi
ANSIBLE_HOME=$(getent passwd "$ANSIBLE_USER" | cut -d: -f6)
mkdir -p "${ANSIBLE_HOME}/vault-bootstrap"
chown "${ANSIBLE_USER}:${ANSIBLE_USER}" "${ANSIBLE_HOME}/vault-bootstrap"
chmod 700 "${ANSIBLE_HOME}/vault-bootstrap"
cat > "${ANSIBLE_HOME}/vault-bootstrap/README.txt" <<EOF
Next manual steps (run as an operator, not via this script):
1. export VAULT_ADDR='https://${VAULT_FQDN}:8200'
2. vault operator init -key-shares=5 -key-threshold=3
3. Store unseal keys and root token in a secure secret manager (not shell history).
4. vault auth enable approle
5. Create least-privilege policy + AppRole, then encrypt the resulting secret_id
with: ansible-vault encrypt_string
EOF
chown "${ANSIBLE_USER}:${ANSIBLE_USER}" "${ANSIBLE_HOME}/vault-bootstrap/README.txt"
chmod 600 "${ANSIBLE_HOME}/vault-bootstrap/
Review the script before running. Execute with: bash install.sh