Ansible Vault vs HashiCorp Vault: which secret goes where

One encrypts files in your repo, the other serves secrets at runtime. Most teams need both — here is the split that works.

Sep 23, 2025·Updated ·6 min readBeginner·By SecOpsLog · documentation-verified

Two tools share a word and solve different problems. Ansible Vault encrypts a file so it can sit in Git next to the playbook that uses it; the playbook decrypts it in memory at run time with a passphrase. HashiCorp Vault is a server that authenticates a caller, hands out a secret (often one it just created, with a lease), and writes an audit line. Choosing between them is a question about the secret, not about the tools: is it small, static and needed before anything else exists, or should it rotate, expire and be traceable to whoever fetched it?

Same word, different jobs
Ansible Vault
Encrypts files and variables at rest
Ciphertext lives in the repository
One passphrase per vault ID
Decrypted in memory during the play
No audit trail beyond Git history
HashiCorp Vault
Serves secrets over an API at run time
A server or cluster to operate
Per-identity auth and policy
Dynamic, leased, revocable credentials
Central audit log of every read

Which secret goes where

Decision table

SecretWhereWhy
Registry pull password for the first imageAnsible VaultNeeded before the host can reach anything else; changes rarely
Initial token that enrols a host with VaultAnsible VaultBootstraps the runtime path; short-lived by design
TLS key for an internal, one-time installAnsible VaultStatic, small, tied to this play
Database credentials for a migrationHashiCorp VaultLeased per run and revoked after; never in Git
Cloud API keys used by a roleHashiCorp VaultRotate on a schedule, scoped per environment
Certificates from an internal PKIHashiCorp VaultIssued on demand with short TTLs
The Vault passphrase itselfCI secret store / password managerIt must not live next to what it protects

Most teams end up with both, and that is the intended shape rather than a compromise: Ansible Vault carries the handful of secrets that have to exist before the runtime path works, and HashiCorp Vault carries everything that should not be a file. The failure mode to avoid is the middle ground, where a production database password lives in an encrypted vars file for three years because moving it felt like a project.

Ansible Vault: files and variables that travel with the play

ansible-vault encrypt turns a whole vars file into ciphertext with an $ANSIBLE_VAULT;1.1;AES256 header; ansible-vault encrypt_string encrypts a single value so a group_vars file can stay mostly readable. Vault IDs label which passphrase encrypted what, so prod and staging files can use different passphrases in the same repository, and the label is recorded in the header ($ANSIBLE_VAULT;1.2;AES256;prod).

bash — encrypt with a labelled vault ID
ansible-vault encrypt --vault-id prod@prompt group_vars/prod/secrets.yml
New vault password (prod): ********
Encryption successful
head -2 group_vars/prod/secrets.yml
$ANSIBLE_VAULT;1.2;AES256;prod
39396264613966… ciphertext, safe to commit
ansible-vault encrypt_string --vault-id prod@prompt --name registry_password 's3cr3t'
registry_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
playbook.yml
# group_vars/prod/secrets.yml is vault-encrypted; nothing here changes
- hosts: prod
tasks:
- name: write app config
ansible.builtin.template:
src: app.conf.j2 # references {{ db_password }}
dest: /etc/app.conf
mode: "0640" # decrypted only in memory, at run time

At run time the passphrase comes from a prompt, a file, or a script. The script form is the one that fits CI: --vault-id prod@./vault-pass-client.py runs the script and reads the passphrase from its stdout, so the passphrase can come from the CI platform's secret store or from HashiCorp Vault itself without ever being written to the runner's disk. A plain --vault-password-file works too, provided the file has mode 0600, is created by the job and removed by it, and is listed in .gitignore before the first commit.

vault-pass-client.py
#!/usr/bin/env python3
# A vault password client: Ansible calls it with --vault-id <label>
# and reads the passphrase from stdout. The value never touches disk.
import os, sys
label = sys.argv[sys.argv.index("--vault-id") + 1] if "--vault-id" in sys.argv else "default"
print(os.environ[f"ANSIBLE_VAULT_PASS_{label.upper()}"])
ci job
# CI variable ANSIBLE_VAULT_PASS_PROD comes from the platform's masked secret store.
# The script name must end in -client or -client.<ext> and be executable.
chmod +x vault-pass-client.py
ansible-playbook playbook.yml --vault-id prod@./vault-pass-client.py
The passphrase is the entire security model
Everything encrypted with a vault ID is exactly as protected as that passphrase. Keep it out of the repository, out of shell history and out of chat, and rekey every file with ansible-vault rekey the moment it may have leaked: the old ciphertext stays in Git history and stays decryptable with the old passphrase. Offboarding someone who knew the passphrase is a rekey, not a reminder.

HashiCorp Vault: look the secret up when the play runs

For anything that should rotate, differ per environment or be revoked centrally, the playbook fetches the value from HashiCorp Vault at run time and stores nothing. The community.hashi_vault.vault_read lookup returns the raw read response, so for a dynamic database credential from database/creds/app the password is under .data.password and the lease id is right next to it. The older hashi_vault lookup still exists but the collection documents a migration away from it; new playbooks should use vault_read, or vault_kv2_get for KV v2 paths.

playbook.yml
- hosts: prod
vars:
ansible_hashi_vault_url: https://vault.internal:8200
ansible_hashi_vault_auth_method: approle # role_id/secret_id from the CI secret store
tasks:
- name: lease one database credential for this play
ansible.builtin.set_fact:
db_lease: "{{ lookup('community.hashi_vault.vault_read', 'database/creds/app') }}"
no_log: true
- name: run migration with that credential
ansible.builtin.command: ./migrate.sh
environment:
DB_USER: "{{ db_lease.data.username }}"
DB_PASS: "{{ db_lease.data.password }}"
no_log: true

Three details make this safe rather than merely dynamic. The lookup runs inside set_fact, once: a lookup placed under vars: is evaluated lazily on every reference, and for a dynamic secret that means db_lease.data.username and db_lease.data.password would come from two different leases. no_log: true keeps the credential out of the play output and the CI log. And the authentication is a method with an identity behind it, approle here or jwt when the CI platform can present an OIDC token, so the Vault audit log shows which pipeline read database/creds/app and the lease can be revoked without touching the playbook.

The remaining decision is who owns the passphrases and the Vault policies once the split exists. The Secrets management foundations course covers that ownership model, at rest, in transit and at run time, and the Ansible course covers the vault ID and lookup mechanics in the context of a full inventory.

Related posts

Quick reference