Skip to content

Ansible Vault and Git: Encryption and Key Management

Lesson 8 of 8Advanced14 min readGit for DevOps & Infrastructure · AnsibleVerified: Ansible Vault documentation, September 2026

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.

Symmetric encryption of files or individual values, with a password.

Terminal window
ansible-vault encrypt group_vars/production/vault.yml
ansible-vault decrypt group_vars/production/vault.yml
ansible-vault edit group_vars/production/vault.yml
ansible-vault view group_vars/production/vault.yml

An encrypted file begins with a recognisable header:

$ANSIBLE_VAULT;1.1;AES256
66383439383166...

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.

Two granularities with different trade-offs.

Whole filegroup_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.com
db_user: app
db_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 — plain
db_host: db.example.com
db_password: "{{ vault_db_password }}"
# group_vars/production/vault.yml — encrypted
vault_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.

Never commit the password. .gitignore it with a default-deny pattern:

.vault_pass
.vault_pass.txt
vault-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.

Multiple passwords in one repository, which is how you avoid everybody holding the production key.

Terminal window
ansible-vault encrypt --vault-id production@prompt group_vars/production/vault.yml
ansible-vault encrypt --vault-id development@prompt group_vars/development/vault.yml
Terminal window
ansible-playbook site.yml \
-i inventories/production/hosts.yml \
--vault-id production@~/.ansible/prod-pass

Each 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-pass

The 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_pass

printf 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.

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:

Terminal window
ansible-vault rekey --new-vault-id production@prompt group_vars/production/vault.yml

Rotating 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.

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.

The comparison worth making before committing to either.

Ansible VaultExternal secrets manager
In the repositoryCiphertextA reference
RotationRe-encrypt, commit, merge, runChange the value
Audit logNoneYes
Access controlOne password per vault IDPer secret, per identity
Runtime dependencyNoneThe manager must be reachable
Ciphertext in historyPermanentNothing to retain
Setup costAlmost noneA service to run or subscribe to
Works offlineYesNo

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.

A repository with plaintext credentials in group_vars, and a decision to fix it.

  1. 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
  2. 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.

  3. Decide on vault IDs — at minimum one per environment — and generate strong passwords for each.

  4. 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.

  5. Split each group_vars file into vars.yml and vault.yml, moving secrets into the second with a vault_ prefix.

  6. Encrypt the vault files with the appropriate ID.

  7. Run a playbook in check mode and confirm the variables resolve. A missing vault_ reference fails at run time, not at encryption time.

  8. Add the .gitignore patterns and the CI header check so a plaintext vault.yml cannot recur.

  9. 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.

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.

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.

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.

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.

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.

  • Vault encrypts files or individual values with a password; encrypt_string keeps diffs readable
  • The vars.yml / vault.yml split 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_file can 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

Use a disposable repository. Placeholder values only — no real credentials.

  1. Create group_vars/production/vault.yml with a placeholder password and encrypt it. Inspect the file. Predict: what does the first line say?

  2. Commit it and view the diff. Predict: can a reviewer tell what changed?

  3. Use ansible-vault encrypt_string to encrypt a single value in an otherwise plain file. Change it and view the diff. Compare.

  4. Add the vault .gitignore patterns. Create .vault_pass and run git status. Predict: is it ignored?

  5. Create two vault IDs — development and production — with different passwords. Try to view the production file with the development password. Predict: what happens?

  6. Run a playbook using the vault variable with --diff and a template task, without no_log. Predict: does the value appear in the output?

  7. Add no_log: true and repeat.

  8. Rekey the production file. Then check out the previous commit and try to decrypt with the old password. Predict: does it still work?

  9. Delete the repository and the password files.

Lab: ship an infrastructure change through a pull requestPractise the review discipline that makes an infrastructure change reviewable before it is applied.

The GitOps and infrastructure repository templates are in the Professional Toolkit.