Chapter 9: Secrets — Ansible Vault
Topic 53

Vault Workflows

WorkflowSecurity

Whole-file encryption is the blunt option, and in practice you reach for finer tools. ansible-vault encrypt_string encrypts one value so a single inline secret can sit in an otherwise-plaintext vars file. The group_vars/<group>/vault.yml pattern splits all of a group's secrets into one encrypted file beside the plaintext one. And vault IDs let you carry more than one key at once — a different passphrase per environment, all decrypted in the same run.

These three techniques are how the Larkspur repo keeps its secrets both encrypted and reviewable. The goal across all of them is the same: shrink the encrypted surface to exactly the values that must stay hidden, so everything else stays plain, diffable, and easy to approve.

Three ways to shrink the encrypted surface
encrypt_string
One inline secret — a !vault block for vault_db_password pasted into an otherwise-plaintext vars file.
vaulted vars file
A whole group_vars/db/vault.yml of secrets beside a plaintext vars.yml that references them by name.
vault IDs
A per-environment key — staging and prod under different passphrases, all decrypted in one run.

encrypt_string for One Value

ansible-vault encrypt_string 's3cr3t' --name vault_db_password emits a !vault-tagged ciphertext block you paste straight into a YAML file. The result is a normal YAML key whose value happens to be an encrypted literal, so vault_db_password is hidden while the rest of the file stays plaintext and diffable. This is the right tool when a file has exactly one secret and a dozen settings, and encrypting the whole file would make those settings opaque for no reason.

encrypt_string emits a !vault block you paste into a plaintext vars file
# generate the encrypted literal for one value
ansible-vault encrypt_string 's3cr3t' --name vault_db_password

# the output, pasted into group_vars/db/vars.yml:
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653...
          31376665383...
db_port: 5432            # stays plaintext, stays diffable

The indentation under the !vault | literal is load-bearing. Ansible parses the block as a YAML scalar, and if the hex lines are not indented consistently the value parses as a broken string and the decode fails. Paste the block exactly as encrypt_string produced it and the literal stays intact.

The vault.yml Split Pattern

Once a group has several secrets, the cleaner pattern is to split them out. group_vars/db/vault.yml holds the encrypted secrets — vault_db_password, vault_app_secret_key — and group_vars/db/vars.yml holds the plaintext settings, referencing the vaulted vars by name. Both files are picked up automatically for any host in the db group, so the role sees a flat set of variables and never knows two files were involved.

The split: a fully-encrypted vault.yml beside a plaintext vars.yml
# group_vars/db/vault.yml  — encrypted as a whole, every diff opaque
vault_db_password: s3cr3t
vault_app_secret_key: a1b2c3d4e5

# group_vars/db/vars.yml  — plaintext, references the vaulted names
db_password: "{{ vault_db_password }}"
app_secret_key: "{{ vault_app_secret_key }}"
db_port: 5432

Because vault.yml contains nothing but secrets, encrypting it whole costs nothing in reviewability — there were no non-secret lines to hide. Every diff on that file is opaque, which is exactly right, and every diff on vars.yml stays readable. You get a fully-encrypted file without ever dragging a plaintext setting into ciphertext.

The vault_ Naming Convention

The indirection in that split is the vault_ prefix, and it is a convention worth following exactly. The secret lives in vault.yml as vault_db_password; vars.yml sets db_password: "{{ vault_db_password }}"; and the db role references the plain name db_password. The role never mentions the vault_ name at all, so the only encrypted file is the small one, and roles stay decoupled from how the secret is stored.

What this buys you is reviewable wiring. A teammate reading vars.yml sees db_password is fed from vault_db_password and can confirm the password is plumbed into the right place — without the value being visible anywhere in plaintext. The prefix is the seam between "is this wired up correctly," which must be reviewable, and "what is the value," which must not be.

Multiple Vault IDs

A vault ID labels a key so more than one can be loaded at once. Running with --vault-id staging@prompt --vault-id prod@prompt loads two keys, each tagged with an environment name. When Ansible meets an encrypted file it tries each loaded key against it and uses whichever one matches, so staging secrets and prod secrets — encrypted under different passphrases — coexist in one repo and decrypt in one run. The @prompt source asks for each passphrase interactively; it can also be @ a password file, covered in the next topic.

Separate IDs per environment exist to contain blast radius. If staging and prod shared one passphrase, a leaked staging key would decrypt prod too. With distinct keys, a compromised staging passphrase is exactly as bad as it should be and no worse — it unlocks staging and stops there.

Encrypting Under a Specific ID

When you encrypt a value while carrying multiple keys, stamp it with the ID it belongs to: ansible-vault encrypt_string --vault-id prod@prompt --encrypt-vault-id prod 's3cr3t' --name vault_db_password. The --encrypt-vault-id prod records which key encrypted the value, so at run time Ansible selects the prod passphrase directly instead of trying every loaded key in turn. On a small repo the difference is cosmetic, but once several environments share a tree it removes ambiguity about which key a given blob needs.

The failure this prevents is a quiet one. Encrypt a value with the staging key but load only the prod vault-id at run time, and Ansible has no key that matches — the play dies with "Decryption failed" on a host you did not expect to be involved. Stamping the ID keeps encryption and decryption talking about the same key.

Common Mistakes
  • Pasting an encrypt_string block but botching the indentation of the !vault literal, so the value parses as a broken string and the task fails with an unhelpful decode error rather than a clear "wrong indentation."
  • Putting db_password: "{{ vault_db_password }}" and vault_db_password in the same encrypted file, so the whole thing is opaque and you lose the readable indirection the split pattern exists to give you.
  • Reusing one vault passphrase for both staging and prod, so a leaked staging key also decrypts prod — separate vault IDs per environment exist precisely to contain the blast radius.
  • Encrypting a value with the staging key but loading only the prod vault-id at run time, so Ansible cannot decrypt it and the play dies with "Decryption failed" on a host you did not expect.
  • Reaching for a full vault.yml to hide a single inline secret, encrypting a file that has one secret and ten settings when encrypt_string would have hidden just the one.
Best Practices
  • Use encrypt_string for a one-off inline secret and a dedicated vault.yml once a group has several secrets, matching the granularity to how many values you are hiding.
  • Follow the vault_-prefix convention so the only encrypted file is the small vault.yml and every plaintext reference stays reviewable.
  • Give staging and prod separate vault IDs and separate passphrases, so one environment's key never decrypts another's.
  • Stamp values with --encrypt-vault-id when you carry multiple keys, so decryption picks the right one instead of trying each loaded key against every blob.
  • Paste an encrypt_string block exactly as the command produced it, preserving its indentation, so the !vault literal parses cleanly.
Comparable tools SOPS per-value and per-environment keys via KMS or age, the closest external analog git-crypt whole-file only, no inline-value granularity sealed-secrets per-cluster encryption for Kubernetes HashiCorp Vault namespaces the per-environment role an external manager plays

Knowledge Check

When does encrypt_string beat encrypting a whole vault.yml?

  • When a file has one secret among many plaintext settings, so you hide just that value and keep the rest diffable
  • When a single high-value secret needs stronger encryption than the AES-256 a whole-file vault uses
  • When the secret must stay readable by a teammate reviewing the file without the vault key
  • When the file will never be committed to git and only ever lives on one engineer's laptop, where inline encryption is the only form that protects a value that no remote runner will ever read

Why does the vault_-prefix split keep diffs reviewable?

  • The plaintext vars.yml shows db_password wired from vault_db_password, so the wiring stays reviewable while the value is encrypted in vault.yml
  • The prefix is a marker that tells git to skip the encrypted file when it computes a diff
  • It encrypts the variable names themselves along with their values, so a reviewer reading the diff cannot tell which secrets the group even has or how many of them sit in the file
  • Ansible refuses to load any variable not prefixed with vault_, enforcing the split at parse time

What do multiple --vault-id keys buy you across environments?

  • Staging and prod secrets under different passphrases coexist in one repo and one run, so a leaked key contains its blast radius to that one environment
  • Ansible encrypts each file once under every loaded vault-id, layering the blobs so that any one of the keys you supply at run time can independently decrypt it for redundancy
  • They let you skip supplying a passphrase entirely for whichever environment you list second
  • They combine into a single stronger composite key that is much harder to brute-force

You encrypt a value with the staging vault-id but load only the prod vault-id at run time. What happens?

  • Ansible has no matching key and the play dies with a decryption failure on the affected host
  • Ansible silently falls back to plaintext and feeds the raw ciphertext through as the value
  • The prod key automatically re-encrypts the staging blob, transparently repairing the mismatch
  • Ansible prompts for the missing staging passphrase interactively so the run can recover

You got correct