SSD Nodes Learn 🎉 VPS from $5.50/mo
Guides Matt ConnorBy Matt Connor

Ansible Vault: encrypt secrets in git

Keep passwords and API tokens in a playbook repo safely: encrypt a vars file or one inline string, split staging from production, and rekey cleanly.

What Ansible Vault protects, and what it does not

Ansible Vault encrypts secrets inside your playbook repository, so what git stores is ciphertext instead of a plaintext password. The ansible-vault command encrypts either a whole file or a single value inside a file, using a symmetric key derived from a password you choose. Ansible decrypts that content in memory when the play runs, so the variable behaves like any other variable.

That model has one clear boundary. Vault protects a secret at rest in the repository and nothing more. Once a task runs, the value is plaintext in memory, in the rendered template, in the module arguments, and in the run output unless you stop it. Everyone who can run the playbook holds the vault password, so vault gives you secrecy from people outside the team, not per-person access control inside it.

If you have not written a playbook yet, start with a first Ansible playbook against a VPS and come back when that playbook needs a password.

Encrypt a whole file, or a single string?

ansible-vault encrypt replaces a file with ciphertext. The file becomes one block of base64 text under a header line beginning with $ANSIBLE_VAULT. Use it when the file contains nothing but secrets.

ansible-vault encrypt_string encrypts one value and prints a YAML snippet you paste into an ordinary vars file. The variable name stays readable and only the value is ciphertext. Use it when secrets sit beside plaintext settings.

The difference that matters in daily work is the diff. A vault file is re-encrypted with a fresh random salt every time you save it, so every byte of the ciphertext changes. git diff then shows one unreadable block replaced by another unreadable block, which means a reviewer cannot tell whether you rotated one password or rewrote the file. With encrypt_string, each secret is its own block inside a plaintext file, so a diff shows exactly which variable changed and leaves the rest of the file alone.

The inline form has a cost, and it arrives at rotation time: ansible-vault rekey does not touch inline blocks. Choose the file form when the secret list is long and changes rarely. Choose the inline form when the file mixes secrets with normal variables and you want code review to mean something.

The group_vars layout that shows what is protected

Ansible loads group_vars/<group>.yml, and it also loads every file inside a group_vars/<group>/ directory. The directory form is the one you want, because it lets a single group carry a plaintext file and an encrypted file side by side.

inventory/
  hosts.ini
group_vars/
  all/
    vars.yml
    vault.yml
  web/
    vars.yml
    vault.yml
host_vars/
  db01/
    vars.yml
    vault.yml
playbooks/
  site.yml

Every vault.yml is encrypted. Every vars.yml is plaintext. A reader can see which values are protected without opening anything, because the filename says so.

The second half of the pattern is indirection. Inside the encrypted file, prefix every variable with vault_.

vault_db_password: "a real password"
vault_grafana_admin_token: "a real token"

Then reference those names from the plaintext file next to it.

db_password: "{{ vault_db_password }}"
grafana_admin_token: "{{ vault_grafana_admin_token }}"

Roles and templates use db_password and never learn where the value came from, which keeps the split between a playbook and a role clean. The plaintext vars.yml doubles as a searchable index: grep -r vault_ group_vars/ lists every secret the repository expects, without decrypting anything. The cost is one extra name per secret, and a typo in a vault_ name surfaces at run time as an undefined variable rather than as a syntax error.

Encrypt one variable with encrypt_string

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  --stdin-name 'vault_db_password'

Type the secret, then press Ctrl-D. --stdin-name reads the value from standard input, which keeps it out of your shell history file. The other form puts the value on the command line, where the shell records it:

ansible-vault encrypt_string --vault-id prod@~/.ansible/vault-prod.txt \
  'a real password' --name 'vault_db_password'

Either way the command prints a YAML block. Paste it into the vars file exactly as printed, because the indentation under the !vault tag is part of the value.

vault_db_password: !vault |
          $ANSIBLE_VAULT;1.2;AES256;prod
          6638643965323633646262656665306333616466396630323136393465356136396436383331
          3131303163306665326539353837343663313762616561306534373963383531613664393332

The !vault tag tells the YAML loader that the scalar is ciphertext rather than text. The header carries the format version, the cipher, and the vault ID label that encrypted it. A value encrypted without a vault ID carries a 1.1 header with no label, which still works and simply tells you less about where the password came from.

Where does the vault password live?

Outside the repository. That is the one rule with no exceptions.

--ask-vault-pass prompts once per run and stores nothing. It suits a laptop, and it does not suit a cron job or a CI runner.

A password file is a plain text file whose first line is the password. Create it empty with tight permissions, then fill it in an editor, so the password never reaches your shell history:

mkdir -p ~/.ansible
install -m 600 /dev/null ~/.ansible/vault-prod.txt
$EDITOR ~/.ansible/vault-prod.txt

Point any command at it with --vault-password-file:

ansible-playbook -i inventory/hosts.ini playbooks/site.yml \
  --vault-password-file ~/.ansible/vault-prod.txt

Repeating that flag on every command is easy to forget, so set it once in ansible.cfg at the root of the repository.

[defaults]
inventory = inventory/hosts.ini
vault_password_file = ~/.ansible/vault-prod.txt

The same setting reads from the environment variable ANSIBLE_VAULT_PASSWORD_FILE, which is how a CI job normally supplies it. The job writes the password from its own credential store into a file in a temporary directory, exports the variable, and deletes the file when the run ends. Add the filename pattern to .gitignore as well, because the path in ansible.cfg is committed, and sooner or later somebody will create the real file inside the checkout.

If the password file is executable, Ansible runs it and reads the password from its standard output instead of reading the file as text. That is how you pull the vault password from a system keyring or a cloud secret manager without writing it to disk at all. A script used through --vault-id has extra requirements: its name must end in -client or in -client plus an extension, it must be executable, it must accept a --vault-id option, and it must print the password to standard output.

Two vault IDs: staging and production

A vault ID is a label attached to a vault password, written as label@source. The source is prompt, the path to a password file, or the path to a client script. Labels let one repository hold secrets under more than one password, so the staging password does not open the production file.

ansible-vault encrypt --vault-id staging@~/.ansible/vault-staging.txt \
  group_vars/staging/vault.yml
ansible-vault encrypt --vault-id prod@~/.ansible/vault-prod.txt \
  group_vars/prod/vault.yml

Pass every ID a run might need:

ansible-playbook playbooks/site.yml \
  --vault-id staging@~/.ansible/vault-staging.txt \
  --vault-id prod@~/.ansible/vault-prod.txt

Or list them once in ansible.cfg:

[defaults]
vault_identity_list = staging@~/.ansible/vault-staging.txt, prod@~/.ansible/vault-prod.txt

One behaviour surprises people. By default the label is a hint, not a lock. Ansible tries every secret it currently holds against the file until one of them decrypts it, so a file labelled staging still opens if the production password happens to be the right key. Set vault_id_match = True under [defaults], or the environment variable ANSIBLE_VAULT_ID_MATCH, and Ansible uses only the secret whose label matches the file header. That check needs the 1.2 header, so it applies only to content that was encrypted with a vault ID in the first place.

With more than one ID loaded, ansible-vault encrypt no longer knows which password to encrypt with. Name it with --encrypt-vault-id prod, or set vault_encrypt_identity in ansible.cfg so the repository has a default.

The payoff is deployment scope. A CI job that deploys staging is given the staging password only, so a compromised runner cannot read production credentials. Once you are running plays across a fleet of Linux servers from one control machine, that separation is the difference between a small incident and a very large one.

Rekey the vault when someone leaves

Rekeying changes the vault password and re-encrypts the content under the new one. It does not undo anything. Anyone who once held the old password can still decrypt any copy of the repository they kept, including every old commit in that copy. So treat the vault password as burned the moment a holder leaves, and rotate in this order.

  1. Change the real credentials on the servers and in the third-party services. This step is the one that actually revokes access.
  2. Put the new values into the vault files with ansible-vault edit.
  3. Rekey every encrypted file to a new vault password.
  4. Hand the new vault password to the people who still need it, over a channel that is not the repository.
ansible-vault rekey --vault-id prod@~/.ansible/vault-prod-old.txt \
  --new-vault-id prod@prompt \
  group_vars/prod/vault.yml host_vars/db01/vault.yml

rekey accepts several files in one command, and --new-vault-id prod@prompt asks for the new password once instead of reading it from disk. Keep the same label unless you have a reason to change it, because the label is written into the header of every file the command rewrites.

This is where the inline form costs you. ansible-vault rekey operates on fully encrypted files, so an !vault block sitting inside a plaintext vars file is left untouched. Find them first, then regenerate each one with encrypt_string under the new password:

grep -rl '!vault' group_vars/ host_vars/

That is the trade in full. Inline blocks give you readable diffs and cost you a manual pass at rotation time. Fully encrypted files rotate with one command and give you nothing useful in review.

Why the secret still appears in your output

Vault is finished the moment the value is decrypted. Ansible reports the result of a task, and a module that echoes its arguments carries the credential into that report. A verbose run, a --diff on a template task, a failed task dumping its arguments, or a callback plugin writing output to a file will each hold the plaintext. Encrypting the file did nothing about any of them.

no_log: true is the switch. Set it on any task that receives a credential.

- name: Write the application environment file
  ansible.builtin.template:
    src: app.env.j2
    dest: /etc/myapp/app.env
    owner: myapp
    group: myapp
    mode: "0600"
  no_log: true

Ansible then withholds that task's result from the output, so the log records that the task ran without recording what it handled. Set it on loops in particular, because a loop reports one result per item, and a loop over a credential list reports the whole list.

Four other places a decrypted secret escapes, none of which no_log covers:

  • A file rendered from a template inherits the mode and owner you gave it. Set mode: "0600" and a specific owner on anything holding a credential, or the secret ends up world readable on the target host.
  • A secret passed to ansible.builtin.command or ansible.builtin.shell appears in the process list on the target host while the command runs, where any local user can read it. Pass it through a file or an environment variable instead.
  • Fact caching writes gathered facts to disk on the control machine, so a registered variable holding a secret can end up in a cache file that nobody thinks of as sensitive.
  • The same secret usually lives in a second place, such as an environment file read by a container. The rules there are separate, and keeping credentials out of Compose env files covers that side of it.

no_log makes debugging harder, which is exactly what it is for. Remove it temporarily on a test host when a task misbehaves, and put it back before the change reaches production.

Read and edit encrypted files without leaving plaintext behind

ansible-vault view group_vars/prod/vault.yml decrypts into a pager and writes nothing to disk. ansible-vault edit decrypts into a temporary file, opens your $EDITOR, and re-encrypts when you close it. Prefer both over ansible-vault decrypt, which leaves a plaintext file sitting in the working tree. A decrypted vault file staged by accident is the most common way a real credential reaches a public repository.

Git can render a readable diff for fully encrypted files by decrypting them as it goes:

git config --local diff.ansible-vault.textconv "ansible-vault view --vault-password-file ~/.ansible/vault-prod.txt"
printf '%s\n' 'group_vars/**/vault.yml diff=ansible-vault' >> .gitattributes

Understand what that does before enabling it. git diff will now print production secrets into your terminal, which puts them in your scrollback and in any screen share. It is a local convenience for one person on one machine, so keep the git config local, and expect other people's checkouts to behave differently unless they set the same thing up.

When vault stops being the right tool

Vault is a file format with one password per label, and that shape decides where it runs out. Move to a real secret store when any of the following is true.

  • You need per-person access. Everyone who runs the playbook holds the same password, and vault IDs split access by environment, never by person.
  • You need an audit trail. Vault records nothing about who decrypted what, or when.
  • You need rotation on a schedule. Vault has no expiry and no versioning, so nothing tells you that a credential has not changed in two years.
  • The application itself needs the secret at run time. A service reading its database password at boot should not be reading it out of your deployment repository.

The pattern then inverts. Ansible stops storing secrets and starts fetching them at run time through a lookup plugin, against HashiCorp Vault (a different product with a confusingly similar name), a cloud provider's secret manager, or a keyring on the control machine. The repository holds a path, the store holds the value, and the store keeps the access log. For a small team, a self-hosted password manager with an API, such as a Vaultwarden server, covers the same job at a smaller size.

One credential stays outside all of this. The SSH key your control machine uses to reach the servers is not a vault problem, because Ansible needs it before any play can run. Handle it with an agent and a passphrase, along the lines of the basics of SSH key management.

FAQ

Should I encrypt the whole vars file or just the secret string?

Encrypt the whole file when it holds nothing but secrets, because one command rotates all of it and the layout stays simple. Use ansible-vault encrypt_string when secrets sit beside ordinary variables, because then only the encrypted value changes in a diff and a reviewer can see which variable was touched. The trade is rotation. ansible-vault rekey covers whole files and leaves inline !vault blocks alone, so those have to be regenerated by hand under the new password.

Where should the Ansible Vault password file be stored?

Outside the repository, with mode 0600, at a path such as ~/.ansible/vault-prod.txt. Point at it with --vault-password-file, or set vault_password_file under [defaults] in ansible.cfg, or set ANSIBLE_VAULT_PASSWORD_FILE in the environment. In CI, have the job write the password from its own credential store into a temporary file, export the variable, and delete the file when the job ends. If the file is executable, Ansible runs it and reads the password from standard output, which lets you source it from a keyring instead of storing it on disk.

How do I use different vault passwords for staging and production?

Give each password a label with --vault-id staging@/path/to/file and --vault-id prod@/path/to/file, and encrypt each environment's files under its own label. Pass both IDs at run time, or list them in vault_identity_list under [defaults]. By default Ansible tries every secret it holds until one decrypts the file, so set vault_id_match = True if you want it to try only the secret whose label matches the file header. With several IDs loaded, choose the encrypting one with --encrypt-vault-id.

Does Ansible Vault stop a password appearing in the run output?

No. Vault protects the secret at rest in the repository only. Once a task runs, the value is plaintext, and a verbose run or a failed task can carry it into the log. Add no_log: true to every task that handles a credential, set a restrictive mode and owner on any file you template out, and avoid passing secrets as command arguments, because those are visible in the process list on the target host while the command runs.