Encrypting a secret before storing it in Git changes the risk model. It does not eliminate your key-management responsibilities.
That sentence is the whole lesson. Vault is genuinely useful and it converts one problem — a credential in a repository — into another — a key that must be protected, distributed, rotated and recovered.
What Vault does
Section titled “What Vault does”Symmetric encryption of files or individual values, with a password.
ansible-vault encrypt group_vars/production/vault.ymlansible-vault decrypt group_vars/production/vault.ymlansible-vault edit group_vars/production/vault.ymlansible-vault view group_vars/production/vault.ymlAn encrypted file begins with a recognisable header:
$ANSIBLE_VAULT;1.1;AES25666383439383166...Ansible decrypts transparently at run time, given the password. Playbooks reference the variables normally, which means an encrypted file is invisible to the rest of the repository — a role consuming vault_db_password does not know or care whether it came from an encrypted file or a plain one.
ansible-vault edit decrypts to a temporary file, opens your editor, and re-encrypts on save. Use it rather than decrypt-edit-encrypt, which leaves a plaintext file on disk between the two commands — and which, sooner or later, somebody commits because they were interrupted between decrypting and re-encrypting. That is one of the commonest ways a plaintext secret reaches a repository that has Vault configured correctly in every other respect.
Encrypt files or values
Section titled “Encrypt files or values”Two granularities with different trade-offs.
Whole file — group_vars/production/vault.yml entirely encrypted. Simple, and the diff is a wall of ciphertext, so a pull request shows that something changed and not what.
Individual values with ansible-vault encrypt_string:
db_host: db.example.comdb_user: appdb_password: !vault | $ANSIBLE_VAULT;1.1;AES256 66383439383166...The structure stays readable and diffable. A reviewer sees that db_password changed and that db_host did not. That is a real advantage — the whole-file approach makes every change opaque.
The convention that works well: a vars.yml with non-secret values and a vault.yml with encrypted ones, where vars.yml references the vault variables:
# group_vars/production/vars.yml — plaindb_host: db.example.comdb_password: "{{ vault_db_password }}"# group_vars/production/vault.yml — encryptedvault_db_password: "..."The vault_ prefix makes it obvious in a playbook which values come from an encrypted file, and vars.yml documents what secrets exist without revealing them.
The password is the whole security model
Section titled “The password is the whole security model”Never commit the password. .gitignore it with a default-deny pattern:
.vault_pass.vault_pass.txtvault-password**.vaultpass.vault_password*Where it should live:
A password manager, for humans. Each engineer retrieves it when they need it.
An environment secret in CI, scoped to the environment, written to a file for the run and removed afterwards.
A password file outside the repository, referenced by ANSIBLE_VAULT_PASSWORD_FILE or ansible.cfg’s vault_password_file. On a laptop, in the home directory, with restrictive permissions.
A script that fetches it from a secret manager. vault_password_file can point at an executable rather than a plain file, and Ansible runs it and reads the password from its stdout. This is the strongest option available within Vault’s model: nothing is stored on disk, access is controlled and logged by the manager, and revoking somebody’s access is a change in one place rather than a rotation of the password itself.
Not in shell history. --vault-password on the command line puts it in history and in process listings.
Vault IDs
Section titled “Vault IDs”Multiple passwords in one repository, which is how you avoid everybody holding the production key.
ansible-vault encrypt --vault-id production@prompt group_vars/production/vault.ymlansible-vault encrypt --vault-id development@prompt group_vars/development/vault.ymlansible-playbook site.yml \ -i inventories/production/hosts.yml \ --vault-id production@~/.ansible/prod-passEach environment gets its own password. A developer with the development password cannot read production’s secrets, which is a meaningful boundary and the main reason to use vault IDs.
Ansible tries the IDs it is given, so a run can supply several.
The label is stored in the file header, which means Ansible can tell which password a file needs and can be given several without trying them all blindly.
Configure defaults in ansible.cfg:
[defaults]vault_identity_list = development@~/.ansible/dev-pass, production@~/.ansible/prod-passThe pattern that matters: production’s password held by fewer people than development’s. Without vault IDs, one password protects everything and everybody who can run any playbook can read every secret.
The validation workflow should not need the password at all.
ansible-lint handles encrypted files without decrypting them. Syntax checks likewise. A validation pipeline needs no vault password, which is the position to aim for.
Where a Molecule scenario needs encrypted test fixtures, use a test vault with obvious placeholder values and a password used nowhere else. That secret in CI is a much smaller exposure than the production one.
The execution workflow needs the real password, as an environment secret:
- name: Run env: ANSIBLE_VAULT_PASSWORD_FILE: /tmp/vault_pass run: | printf '%s' "${{ secrets.VAULT_PASSWORD }}" > /tmp/vault_pass chmod 0600 /tmp/vault_pass ansible-playbook -i "inventories/${{ inputs.environment }}/hosts.yml" site.yml
- name: Clean up if: always() run: rm -f /tmp/vault_passprintf rather than echo, to avoid a trailing newline being part of the password.
Cleanup with if: always(), so a failed run does not leave it.
An environment secret, not a repository secret, so a development job cannot read production’s.
Never --vault-password on the command line in a workflow — it appears in the log if the step echoes commands, and in the runner’s process listing.
Rotation
Section titled “Rotation”The operational property teams neglect, and the one that determines whether Vault is a control or a formality.
Rotating the password means re-encrypting every file with the new one:
ansible-vault rekey --new-vault-id production@prompt group_vars/production/vault.ymlRotating the secrets themselves is the more important operation and is entirely separate. Changing a database password means changing it in the database, re-encrypting the file, committing, reviewing, merging, and running the playbook that distributes it.
That is slow, which is the honest cost of this approach compared with an external secret manager where rotation is a value change with no commit.
The ciphertext in history is permanent. Rotating the password does not remove old ciphertext encrypted with the old one. If the old password is compromised, every value ever encrypted with it — including ones you have since rotated — is exposed. This property is routinely underestimated.
What that means practically: a password compromise is not fixed by rotating the password. It requires rotating every secret that was ever encrypted with it, which is a much larger operation.
Rotate on a schedule. A team that has rotated recently can do it under pressure. One that never has discovers the missing steps during an incident.
What Vault protects, and what it does not
Section titled “What Vault protects, and what it does not”Being precise, because it is often over-trusted.
It protects against repository read access. Somebody who clones the repository gets ciphertext.
It does not protect against anybody with the password. Which, without vault IDs, is everybody who can run a playbook.
It does not protect the decrypted value at run time. The secret is in memory on the control node, written to files on managed hosts, and visible in --diff output unless tasks handling it carry no_log: true.
It does not protect against the ciphertext being retained. History is permanent.
It gives you no audit log. Nothing records who decrypted what and when. Anybody with the password can read every secret it protects, at any time, leaving no trace. That is the specific capability an external secret manager provides and Vault structurally cannot, and it is frequently the requirement that decides the question for a regulated team.
It does not rotate anything. That is your operation.
Vault or a secrets manager
Section titled “Vault or a secrets manager”The comparison worth making before committing to either.
| Ansible Vault | External secrets manager | |
|---|---|---|
| In the repository | Ciphertext | A reference |
| Rotation | Re-encrypt, commit, merge, run | Change the value |
| Audit log | None | Yes |
| Access control | One password per vault ID | Per secret, per identity |
| Runtime dependency | None | The manager must be reachable |
| Ciphertext in history | Permanent | Nothing to retain |
| Setup cost | Almost none | A service to run or subscribe to |
| Works offline | Yes | No |
Vault is right when you have no secrets manager, the estate is small, the number of secrets is modest, and offline operation matters.
A manager is right when rotation needs to be cheap, an audit log is required, access should be per-secret, or the number of secrets has grown past what one shared password sensibly protects.
Ansible has lookup plugins for the common managers, so a playbook can fetch a secret at run time rather than reading it from an encrypted file:
db_password: "{{ lookup('community.hashi_vault.vault_kv2_get', 'app/db').secret.password }}"Nothing secret is then in the repository at all, which is the strongest position — and it introduces a runtime dependency and an authentication problem for the control node, which is the trade.
A reasonable middle path: Vault for the small number of secrets that must work offline — the bootstrap credential for the manager itself, typically — and the manager for everything else. That is a coherent design where each mechanism has a stated job.
What to avoid is two mechanisms adopted by accident, with no rule about which secret lives where. That produces a repository where finding a credential means checking two places, rotation procedures that differ per secret for no reason, and nobody able to answer how many secrets the team actually has.
Adopting Vault on an existing repository
Section titled “Adopting Vault on an existing repository”A repository with plaintext credentials in group_vars, and a decision to fix it.
-
Find them all. Search history, not just the working tree:
Terminal window git log --all -p -- 'group_vars/**' 'host_vars/**' \| grep -iE '^\+.*(password|passwd|secret|token|api_key|private_key)' | head -50 -
Rotate everything you find, first. The credentials are exposed in every clone and every fork, and they are valid while you do the rest of this. Rotation comes first, always.
-
Decide on vault IDs — at minimum one per environment — and generate strong passwords for each.
-
Store the passwords properly before encrypting anything. A password manager for humans, environment secrets for CI. Encrypting first and working out storage afterwards is how a password ends up in a chat message.
-
Split each
group_varsfile intovars.ymlandvault.yml, moving secrets into the second with avault_prefix. -
Encrypt the vault files with the appropriate ID.
-
Run a playbook in check mode and confirm the variables resolve. A missing
vault_reference fails at run time, not at encryption time. -
Add the
.gitignorepatterns and the CI header check so a plaintextvault.ymlcannot recur. -
Consider history. Rewriting removes the plaintext from your repository and does not reach existing clones or forks — which is why step 2 is the one that actually fixes the exposure.
Step 2 has a deadline; the rest can take a week. The commonest sequencing error is doing the encryption work first because it feels like progress, while the exposed credential stays valid throughout.
Secrets on the managed host
Section titled “Secrets on the managed host”The part that Vault says nothing about and that matters as much.
A secret decrypted at run time is written somewhere. A template rendering a configuration file with a password puts that password on disk on the managed host, in plaintext, for as long as the file exists.
Set the mode and owner explicitly. mode: "0600" and an owner that is the service account, not root and not world-readable. This is part of the task, not an afterthought, and the default is more permissive than you want.
Environment variables in a systemd unit are visible to anybody who can read the unit file or inspect the process environment. An EnvironmentFile with restrictive permissions is better.
Command-line arguments are visible in process listings to every user on the host. Never pass a secret as an argument.
Consider whether it needs to be on disk at all. An application that can fetch its own secret from a manager at startup, using an identity the host holds, does not need Ansible to place it — which removes both the Vault problem and the on-disk problem.
no_log: true on the task, so the value does not appear in output or --diff.
The whole chain matters. A secret carefully encrypted in the repository, decrypted with a well-managed password, and then written world-readable to /etc/app/config has been protected everywhere except where it ends up. That last step is the one nobody reviews, and it is the one an attacker on the host actually encounters.
Common mistakes
Section titled “Common mistakes”A committed vault password. Every encrypted file is plaintext, permanently.
A file named vault.yml that is not encrypted. The name implies protection that is not there — and it is why the CI check for the $ANSIBLE_VAULT header is worth having.
One password for every environment. Everybody who can run development can read production.
--vault-password on the command line. Shell history and process listings.
Decrypt, edit, encrypt. A plaintext file on disk, and eventually in a commit.
Assuming rotating the password fixes a compromise. Old ciphertext remains decryptable.
No no_log on tasks handling secrets. Credentials in --diff output and CI logs.
Whole-file encryption when values would do. Every change is opaque to a reviewer.
Repository secrets rather than environment secrets in CI. Every job can read it.
Never rotating. The first attempt happens during an incident.
What to encrypt, and what not to
Section titled “What to encrypt, and what not to”Encrypting everything is as unhelpful as encrypting nothing.
Encrypt: passwords, API keys and tokens, private keys and certificates, database connection strings containing credentials, and anything whose disclosure would matter.
Do not encrypt: hostnames, ports, feature flags, timeouts, package versions, or file paths. Encrypting them makes the repository harder to read and review for no security benefit, and every additional encrypted file is another thing a reviewer cannot see.
The grey area is topology. Internal hostnames and network layout are operational detail rather than credentials. Whether they warrant encryption depends on who can read the repository — and if the answer is “more people than should know our internal topology”, the better fix is repository access rather than encryption.
Do not encrypt whole inventories. An encrypted hosts.yml means nobody can review a change to which machines a playbook targets, which is the most consequential change in an Ansible repository.
The test: would a reviewer need to see this value to review a change? If yes, encrypting it makes reviews worse. If it is a credential, they do not need to see it and should not.
Keep the encrypted surface small. A repository where one file per environment is encrypted is one where the security-relevant content is obvious. One where half the files are ciphertext is one nobody can review properly, and the review is the control that catches everything encryption does not.
Ownership and access
Section titled “Ownership and access”Who holds which password is an organisational decision that encryption makes visible.
Fewer people should hold production’s password than development’s. That is the point of vault IDs, and it is worth being deliberate about the list rather than letting it grow.
Put encrypted files under CODEOWNERS. A change to a vault file should be reviewed by somebody who can decrypt it — otherwise the review is somebody approving a diff of ciphertext, which is not a review.
Record who holds each password. A short list in the platform documentation. When somebody leaves, that list is what tells you which passwords need rotating.
Rotate when somebody with access leaves. This is the operation teams skip, and it is the one whose absence means a departed engineer retains access to production secrets indefinitely.
A password nobody can find is a repository nobody can run. The other failure: a vault password held by one person who is on holiday. Whatever the storage mechanism, more than one person must be able to retrieve it.
Mental model
Section titled “Mental model”Vault turns a secret in a repository into a key you must manage. That is a better position, and it is a position with its own obligations: distribution, rotation, recovery and the knowledge that the ciphertext is permanent.
The question that decides whether it is enough: could you rotate every secret in this repository today, if the password were compromised? If the answer is no, Vault is doing less for you than it appears to.
What you learned
Section titled “What you learned”- Vault encrypts files or individual values with a password;
encrypt_stringkeeps diffs readable - The
vars.yml/vault.ymlsplit documents what secrets exist without revealing them - A committed password makes every encrypted file plaintext permanently, including history
- Vault IDs give a password per environment, so development cannot read production
- A
vault_password_filecan be an executable that fetches from a manager — the strongest option - Validation pipelines need no vault password; lint handles encrypted files
- Ciphertext in history is permanent: a password compromise means rotating every secret ever encrypted with it
- Vault has no audit log, and rotation is a commit rather than a value change
Exercise
Section titled “Exercise”Use a disposable repository. Placeholder values only — no real credentials.
-
Create
group_vars/production/vault.ymlwith a placeholder password and encrypt it. Inspect the file. Predict: what does the first line say? -
Commit it and view the diff. Predict: can a reviewer tell what changed?
-
Use
ansible-vault encrypt_stringto encrypt a single value in an otherwise plain file. Change it and view the diff. Compare. -
Add the vault
.gitignorepatterns. Create.vault_passand rungit status. Predict: is it ignored? -
Create two vault IDs — development and production — with different passwords. Try to view the production file with the development password. Predict: what happens?
-
Run a playbook using the vault variable with
--diffand a template task, withoutno_log. Predict: does the value appear in the output? -
Add
no_log: trueand repeat. -
Rekey the production file. Then check out the previous commit and try to decrypt with the old password. Predict: does it still work?
-
Delete the repository and the password files.
Related lessons
Section titled “Related lessons”The GitOps and infrastructure repository templates are in the Professional Toolkit.