Module 07 — Secrets & Ansible Vault

Updated 20 August 2026

Module 07 · Secrets & Ansible Vault

Keeping passwords, keys and tokens out of Git without making automation impossible. Module 06 left you with a group_vars/vault.yml sitting there in plaintext — this module fixes that.

🧠 concept → 🧪 exercise → ✅ expected result (hidden) → 🎯 interview questions (answers hidden)

Prerequisite: Modules 01–06.


Part A · Ansible Vault fundamentals

A1 · What Vault is, and what it is not

The analogy. Think of a diary with a small lock on it. It genuinely stops your brother reading it, which is exactly what you wanted — so it is not a toy. But notice its three limits, because they are the same three Vault has. The one little key opens the whole diary, so you cannot let someone read March but not April. It keeps no record of who opened it. And changing the lock today does not un-read the pages he saw last year.

Ansible Vault is that diary lock: good at the job it has, and honest about the jobs it does not do. The third limit is the one that matters most in practice — when someone leaves the team, you have to rotate the actual passwords, not just the vault password, because they already read the pages.

Ansible Vault is symmetric AES256 encryption for files in your repository. One password encrypts, the same password decrypts. That is the entire model.

Vault isVault is not
Encryption at rest — safe to commit to GitA secrets manager — no rotation, no audit log, no access policy
Built in, no infrastructure to runPer-user access control — one password, everyone who has it sees everything
Transparent at run time — playbooks just workProtection at run time — values are plaintext in memory and can leak to logs
Good enough for most teamsA substitute for HashiCorp Vault or AWS Secrets Manager at scale
The threat model, stated plainly. Vault protects against someone reading your Git repository. It does not protect against someone who can run your playbooks — they have the password by definition. It does not protect against a secret being printed into a CI log by a task without no_log. And revoking access means rotating the password and re-encrypting every file, because anyone who ever had it can decrypt any commit in your history.

Saying this out loud in an interview is worth more than reciting the commands.

A2 · The core commands

bash
ansible-vault create secrets.yml           # create a new encrypted file in $EDITOR
ansible-vault edit secrets.yml             # ⭐ decrypt, open in $EDITOR, re-encrypt on save
ansible-vault view secrets.yml             # ⭐ print decrypted, do not modify
ansible-vault encrypt existing.yml         # encrypt a file that already exists
ansible-vault decrypt secrets.yml          # ⚠️ permanently decrypt on disk
ansible-vault rekey secrets.yml            # ⭐ change the password
ansible-vault encrypt_string 'value'       # ⭐ encrypt ONE value for pasting inline
Never decrypt a file you intend to keep. It writes plaintext to disk, and the next git add -A commits it. Use view to read and edit to change — both keep the file encrypted at rest. decrypt exists for deliberately retiring a file from Vault, not for looking at it.

Running a playbook that needs a password

bash
ansible-playbook site.yml --ask-vault-pass                    # prompt
ansible-playbook site.yml --vault-password-file ~/.vault-pass # ⭐ a file
export ANSIBLE_VAULT_PASSWORD_FILE=~/.vault-pass              # ⭐ env var
plain text
# ansible.cfg — the everyday convention
[defaults]
vault_password_file = ~/.ansible-vault-pass     # path is committed; the FILE is not
The password file must never be in the repository. Put it in your home directory, chmod 600, and add the pattern to .gitignore as a second line of defence. Committing the path in ansible.cfg is fine and useful — committing the file is a breach.

A3 · Whole files versus single variables

The analogy. Think of two ways to keep something in a document private. You can seal the whole page inside an envelope — nobody sees anything at all, including the fact that only one line changed since last week. Or you can hand the page over with a black bar drawn across the sensitive line, so the structure stays readable and anyone reviewing it can see which line was covered and that the rest is untouched.

Whole-file encryption is the envelope; encrypt_string is the black bar. That is precisely why a reviewer can look at git diff on an encrypt_string file and see that exactly one secret rotated, while an encrypted whole file shows up as an unreadable wall of changed characters every single time.

Encrypt a whole file

ansible-vault encrypt group_vars/prod/vault.yml

✅ Simple, everything inside is protected

❌ Opaque in git diff — every change looks like the whole file changed

❌ Cannot see what changed in review

Encrypt a single variable

ansible-vault encrypt_string 'pw' --name 'db_pass'

✅ The rest of the file stays readable and diffable

✅ Review shows which secret changed

❌ Noisy — a wall of base64 in an otherwise clean file

bash
ansible-vault encrypt_string 'SuperSecret123' --name 'vault_db_password'
yaml
# Paste the output straight into a normal, unencrypted vars file
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653236336462626566653063336164663966303231363934653561363964363833313662
          6431626536303530376336343832656537303632313433360a626438346336353331386135323734
          62656361653630373231613662633962316233633936396165386439616533343337663461363537
          3335313962373435640a333234323439353564373662633436633537373234376266393936623463
          3234
🧪 Exercise A3.1 — Encrypt both ways and compare the git diff
bash
git init vaultlab && cd vaultlab
mkdir -p group_vars/prod
echo 'vaultpass123' > ~/.vault-lab-pass && chmod 600 ~/.vault-lab-pass
export ANSIBLE_VAULT_PASSWORD_FILE=~/.vault-lab-pass

# Approach 1: whole file
cat > group_vars/prod/vault.yml <<'EOF'
---
vault_db_password: SuperSecret123
vault_api_token: tok_abc123
EOF
ansible-vault encrypt group_vars/prod/vault.yml

# Approach 2: single variable inside a readable file
ansible-vault encrypt_string 'SuperSecret123' --name 'vault_db_password' \
  > /tmp/enc.txt
{ echo '---'; echo 'db_host: db01.example.com'; echo 'db_port: 5432'; cat /tmp/enc.txt; } \
  > group_vars/prod/mixed.yml

git add -A && git commit -qm "secrets"
head -3 group_vars/prod/vault.yml
echo '---'
head -8 group_vars/prod/mixed.yml
Expected result — click to reveal

Whole-file encryption — nothing is legible:

plain text
$ANSIBLE_VAULT;1.1;AES256
35646166326566373432653066363966613464393633613864343739383464643037313961363764
3234353361666232383735623564373135643163373339650a6234623463303433353862373635

Single-variable — structure stays visible:

plain text
---
db_host: db01.example.com
db_port: 5432
vault_db_password: !vault |
          $ANSIBLE_VAULT;1.1;AES256
          66386439653236336462626566653063336164663966303231363934653561363964363833
          6431626536303530376336343832656537303632313433360a6262383463363533313861

Now change one value in each and look at git diff. The whole-file version shows every line as changed, because the ciphertext of the entire file differs. A reviewer sees only "vault.yml changed" and has no way to tell whether you rotated one password or replaced all of them.

The single-variable version shows exactly one variable's block changing, with db_host and db_port untouched and readable. A reviewer can see the shape of the change without ever seeing the secret.

When to choose which — the answer interviewers want: whole-file for a dedicated secrets file where everything in it is sensitive; encrypt_string when secrets are mixed into configuration that benefits from being reviewable. Many teams use both.

🎯 Interview questions — Vault basics

Q. What is Ansible Vault and what does it protect against?

Symmetric AES256 encryption for files in your repository, so secrets can be committed to Git safely. It is built into Ansible with no infrastructure to run, and decryption is transparent at run time.

It protects against someone reading your repository. It does not protect against someone who can run your playbooks — they have the password. It offers no rotation, no audit log and no per-user access control, and secrets are plaintext in memory at run time and can leak into logs without no_log.

Q. Whole-file encryption or encrypt_string — which and why?

Whole-file for a dedicated secrets file where everything is sensitive; it is simpler and there is nothing to review anyway.

encrypt_string when secrets sit alongside ordinary configuration, because the rest of the file stays readable and git diff shows which secret changed without revealing it. Whole-file encryption makes every change look like the entire file was rewritten, so review is impossible.

Most mature repositories use both, choosing per file.

Q. Why should you never use ansible-vault decrypt on a working file?

It writes plaintext to disk permanently, and the next git add -A commits the secret. Use view to read and edit to modify — both keep the file encrypted at rest.

decrypt exists for deliberately retiring a file from Vault, not for inspecting it.

Q. Where does the vault password itself live?

Never in the repository. In a file in the user's home directory with 0600 permissions, referenced by vault_password_file in ansible.cfg — committing the path is fine, committing the file is a breach. Add the pattern to .gitignore as a second line of defence.

In CI it comes from the platform's secret store, written to a temporary file for the run. In AWX it is a Vault credential managed by the platform.


Part B · Vault in a real repository

B1 · The indirection pattern

The analogy. Think of the safe in a hotel wardrobe. The housekeeping list says "Room 402 has a safe". It does not say what is inside, and it does not need to — the existence of the safe is public information, the contents are not. That one harmless line is what lets a manager confirm every room has a safe without opening a single one.

That is the indirection pattern. Your vars.yml says db_password: "{{ vault_db_password }}" — fully readable, fully reviewable in a pull request, and it tells anyone looking that a database password exists and where it gets used, without revealing one character of it.

The convention every mature Ansible repository uses:

plain text
inventories/production/group_vars/prod/
  vars.yml         <- readable. References vault_ variables
  vault.yml        <- encrypted. Defines ONLY vault_ variables
yaml
# vars.yml  -- committed readable, fully reviewable
---
db_host: db01.example.com
db_port: 5432
db_user: appuser
db_password: "{{ vault_db_password }}"      # indirection
api_token: "{{ vault_api_token }}"
yaml
# vault.yml  -- encrypted. Contains nothing but the secrets
---
vault_db_password: SuperSecret123
vault_api_token: tok_abc123
Why bother with two files instead of one encrypted file?

You can see what exists without decrypting. vars.yml tells any reviewer that a db_password is required and where it comes from, while the value stays sealed.

Playbooks never reference vault_* directly. They use db_password, so you can swap the backing store later — to HashiCorp Vault or AWS Secrets Manager — by changing one line in vars.yml and touching nothing else.

Grep works. grep -r vault_ --include=vars.yml lists every secret the project consumes, which is exactly what a security review asks for.

The vault_ prefix is a convention, not a requirement — but it is a universal one, and its absence reads as inexperience.

B2 · Vault IDs — more than one password

The analogy. Think of the keys on your keyring. The house key does not open the office, and the office key does not open the car. That is not an inconvenience anyone is trying to fix — it is the entire point, because lending someone your car key for the weekend does not also hand them your home.

Vault IDs are separate keys instead of one master key. A single password for everything feels convenient right up to the first time you need to give somebody access to one thing — and without vault IDs, anyone who can deploy to development can also decrypt every production secret in the repository.

One password for everything means the intern who deploys to dev can decrypt production. Vault IDs fix that.

bash
# Create files under different identities
ansible-vault create --vault-id dev@prompt   group_vars/dev/vault.yml
ansible-vault create --vault-id prod@prompt  group_vars/prod/vault.yml

# Supply several at once - Ansible matches each file to its label
ansible-playbook site.yml \
  --vault-id dev@~/.vault-dev \
  --vault-id prod@~/.vault-prod
plain text
# ansible.cfg
[defaults]
vault_identity_list = dev@~/.vault-dev, prod@~/.vault-prod
Source syntaxMeaning
prod@promptAsk interactively for the prod password
prod@~/.vault-prodRead it from a file
prod@/usr/local/bin/get-pass.shExecute a script and read the password from stdout
The script form is the one that scales. A vault password client script can fetch the password from HashiCorp Vault, AWS Secrets Manager, or your CI platform at run time — so no password file exists on disk anywhere. Ansible only requires that the script print the password to stdout and exit 0.

This is the bridge between "Vault is too simple for us" and "we need to replace Vault entirely" — often you just need the password itself to come from somewhere real.

B3 · Preventing leaks at run time

The analogy. Think of a cashier at a bank counter. They type your PIN into the terminal; they do not read it back to you aloud to confirm it, and it is not printed on the receipt. However strong the bank's safe is, none of it helps if the number gets announced across a busy branch.

Vault is the safe — it protects the file at rest. no_log: true is not saying the number out loud. The two solve completely different problems, which is why a perfectly encrypted secret can still end up leaked: once a decrypted password is printed into a CI log, it is in that log forever, searchable by everyone with access to the build history.

Vault protects the file. It does not stop a task printing the decrypted value into your CI log.

yaml
- name: Create the database user
  community.postgresql.postgresql_user:
    name: "{{ db_user }}"
    password: "{{ db_password }}"
  no_log: true                          # ⭐ suppress arguments AND output

- name: Loop over credentials
  ansible.builtin.debug:
    msg: "configuring {{ item.name }}"
  loop: "{{ db_users }}"
  loop_control:
    label: "{{ item.name }}"            # ⭐ Module 03 - hides the rest of the dict
  no_log: true
Three places secrets leak even with Vault working perfectly:
  1. Task output. A module echoing its arguments on failure prints the password. no_log: true is the fix.
  2. Loop item echoes. Ansible prints (item={'name': 'x', 'password': 'y'}) for every iteration. loop_control: label: or no_log.
  3. -vvv debugging. Verbose mode prints the full module arguments. no_log still suppresses them — which is exactly why it must be on the task rather than something you remember to avoid.

no_log: true on a task replaces its entire output with censored. The cost is that debugging that task becomes harder, which is the trade you are making deliberately.

🧪 Exercise B3.1 — Watch a secret leak, then stop it
yaml
---
- name: Secret leakage
  hosts: localhost
  gather_facts: false
  vars:
    db_password: "SuperSecret123"
  tasks:
    - name: LEAKS - the argument is echoed
      ansible.builtin.command: "echo connecting with {{ db_password }}"
      register: r1
      changed_when: false

    - name: LEAKS - and so does the registered result
      ansible.builtin.debug:
        var: r1.stdout

    - name: SAFE - no_log censors everything
      ansible.builtin.command: "echo connecting with {{ db_password }}"
      no_log: true
      changed_when: false
bash
ansible-playbook leak.yml
ansible-playbook leak.yml -vvv 2>&1 | grep -c SuperSecret123
Expected result — click to reveal
plain text
TASK [LEAKS - the argument is echoed] ******************************
ok: [localhost]

TASK [LEAKS - and so does the registered result] *******************
ok: [localhost] => {
    "r1.stdout": "connecting with SuperSecret123"
}

TASK [SAFE - no_log censors everything] ****************************
ok: [localhost] => {"censored": "the output has been hidden due to the fact that 'no_log: true' was specified for this task"}

With -vvv the count is high — the password appears in the constructed command line, in the module arguments, and in the result object, several times over.

Vault did its job perfectly here. The file on disk was encrypted, the value was decrypted correctly at run time — and then printed to standard output, into CI logs, into log aggregation, and into anyone's terminal scrollback.

The habit to build: no_log: true goes on any task that touches a secret, at the moment you write it. Adding it later means auditing every log you have ever produced. And note the third task shows nothing useful at all — that is the deliberate cost, and why you use no_log surgically rather than everywhere.

🎯 Interview questions — Vault in practice

Q. Describe how you structure vaulted variables in a repository.

The indirection pattern: a readable vars.yml and an encrypted vault.yml in the same group_vars directory. vault.yml defines only vault_-prefixed variables; vars.yml maps them — db_password: "{{ vault_db_password }}".

Three benefits: reviewers can see which secrets exist without decrypting; playbooks reference db_password rather than vault_db_password, so the backing store can be swapped later without touching them; and grep vault_ enumerates every secret the project consumes, which is what a security review asks for.

Q. What are Vault IDs and why use them?

Labelled vault passwords — --vault-id prod@~/.vault-prod — so different files can be encrypted with different passwords and Ansible matches each file to its label automatically.

The reason: one password for everything means anyone who can deploy to dev can decrypt production. Vault IDs give per-environment separation.

The source can be prompt, a file, or an executable script, and the script form is what scales — it can fetch the password from HashiCorp Vault or a CI secret store at run time, so no password file exists on disk.

Q. Vault is working correctly. How can a secret still leak?

Vault encrypts at rest, not at run time. The decrypted value can reach logs through task output, through the item echo on a loop over dictionaries, and through -vvv verbose output which prints full module arguments.

Fixes: no_log: true on any task handling secrets, loop_control: label: on loops over credential dictionaries, and never routinely running production playbooks at -vvv.

no_log also makes that task undebuggable, which is the deliberate trade.

Q. How do you rotate a vault password?

ansible-vault rekey on every encrypted file, then distribute the new password through your secret store.

The uncomfortable part worth volunteering: rekeying does not protect history. Anyone who ever had the old password can decrypt any older commit. Genuine revocation means rotating the underlying secrets themselves — the database passwords and API tokens — not just the vault password.

That limitation is a large part of why teams move to a real secrets manager.


Part C · Vault in automation

C1 · CI/CD

yaml
# .gitlab-ci.yml
deploy:
  script:
    - echo "$ANSIBLE_VAULT_PASSWORD" > /tmp/.vpass
    - chmod 600 /tmp/.vpass
    - ansible-playbook -i inventories/production playbooks/site.yml
        --vault-password-file /tmp/.vpass
  after_script:
    - shred -u /tmp/.vpass || rm -f /tmp/.vpass
yaml
# GitHub Actions
- name: Run playbook
  env:
    ANSIBLE_VAULT_PASSWORD: ${{ secrets.ANSIBLE_VAULT_PASSWORD }}
  run: |
    printf '%s' "$ANSIBLE_VAULT_PASSWORD" > "$RUNNER_TEMP/.vpass"
    chmod 600 "$RUNNER_TEMP/.vpass"
    ansible-playbook -i inventories/production playbooks/site.yml \
      --vault-password-file "$RUNNER_TEMP/.vpass"
    shred -u "$RUNNER_TEMP/.vpass"
Do not pass the password as a command-line argument. It appears in the process list, in shell history, and often in CI job logs. Write it to a 0600 temp file, use --vault-password-file, and remove it in an after_script that runs even on failure.

Better still, use a client script so the password is never written to disk at all — the script fetches it from the platform's secret store when Ansible asks.

C2 · The client-script approach

bash
#!/usr/bin/env bash
# /usr/local/bin/vault-pass-prod.sh   (chmod 0700)
# Must print ONLY the password to stdout and exit 0
set -euo pipefail
aws secretsmanager get-secret-value \
  --secret-id ansible/vault/prod \
  --query SecretString --output text
plain text
[defaults]
vault_identity_list = prod@/usr/local/bin/vault-pass-prod.sh
This is the answer that lands well in an interview. The vault password lives in AWS Secrets Manager (or HashiCorp Vault), fetched at run time by a script, authenticated by the runner's IAM role. Nothing is on disk, access is IAM-controlled and audited, and rotating the vault password means updating one secret rather than redistributing a file to every engineer.

You keep Vault's simplicity for the content and gain a real secrets manager for the key to it.


Part D · When Vault is not enough

D1 · The alternatives

The analogy. Think of a home safe versus a bank vault. The home safe is cheap, needs no paperwork and works during a power cut — for a passport and some cash it is exactly the right tool, and paying for a bank vault instead would be silly. The bank vault has a signing register, per-person access, and the ability to remove one person from the list without re-keying anything for everybody else.

That register is the whole difference, and it is the same limitation as the diary lock in A1. The moment you need to answer "who opened this, when, and can we revoke just one person?", the home safe stops being enough — and that is the precise point where Ansible Vault gives way to HashiCorp Vault or AWS Secrets Manager. Saying exactly that in an interview is a much better answer than "Vault is insecure", which is not true.

OptionStrengthCost
Ansible VaultBuilt in, no infrastructure, works offlineNo rotation, no audit, no per-user access, history is permanently readable
HashiCorp VaultDynamic short-lived credentials, full audit log, fine-grained policyA cluster to run and secure
AWS Secrets Manager / SSMIAM-controlled, audited via CloudTrail, automatic rotationAWS-only; a network call per lookup
Mozilla SOPSEncrypted in Git like Vault, but with KMS/age keys and diffable outputAn extra tool; needs a KMS or key management
yaml
# HashiCorp Vault at run time - nothing sensitive in the repo at all
- name: Fetch the database password
  ansible.builtin.set_fact:
    db_password: "{{ lookup('community.hashi_vault.hashi_vault',
                     'secret=secret/data/prod/db:value') }}"
  no_log: true

# AWS Secrets Manager
- name: Fetch from Secrets Manager
  ansible.builtin.set_fact:
    db_password: "{{ lookup('amazon.aws.aws_secret', 'prod/db/password') }}"
  no_log: true
Remember from Module 02: lookups run on the control node. So the control node needs network access and credentials for the secret store, and every host in a loop triggers its own lookup unless you fetch once with run_once and distribute via hostvars. That is a real performance consideration on a large fleet.

D2 · Choosing

The answer that shows judgement rather than dogma:

"Ansible Vault for a small team with a modest number of static secrets — it is built in, needs no infrastructure, and works offline. Once you need rotation, audit trails, per-user access, or dynamic short-lived credentials, it stops being sufficient and you move the secrets into HashiCorp Vault or AWS Secrets Manager, pulled at run time by a lookup. A useful middle step is keeping Vault for the content but fetching the vault password itself from a real secrets manager via a client script."

Someone who says "always use HashiCorp Vault" has not run a three-person team; someone who says "Vault is fine" for a regulated environment has not been audited.

🧪 Exercise D2.1 — Make encrypted files diffable in review
bash
# Teach git how to render vault files as plaintext in a diff
git config --local diff.ansible-vault.textconv "ansible-vault view"
printf 'group_vars/**/vault.yml diff=ansible-vault\n' >> .gitattributes
printf '*.vault diff=ansible-vault\n' >> .gitattributes

ansible-vault edit group_vars/prod/vault.yml     # change one value
git diff group_vars/prod/vault.yml
Expected result — click to reveal

Without the textconv setting:

plain text
diff --git a/group_vars/prod/vault.yml b/group_vars/prod/vault.yml
index 8f3a2c1..b7e9d04 100644
Binary files differ

With it:

plain text
diff --git a/group_vars/prod/vault.yml b/group_vars/prod/vault.yml
--- a/group_vars/prod/vault.yml
+++ b/group_vars/prod/vault.yml
@@ -1,3 +1,3 @@
 ---
-vault_db_password: SuperSecret123
+vault_db_password: EvenBetterSecret456
 vault_api_token: tok_abc123

A reviewer can now see that exactly one secret rotated, rather than "the file changed somehow".

🚨 Understand precisely what you have done, and the limits. This decrypts locally, for anyone who already has the vault password — it does not weaken the repository, and the committed file is still ciphertext. But it means the plaintext is now rendered by git diff, git log -p and git show on your machine, so it can end up in a terminal recording, a screen share, or a pager's scrollback.

.gitattributes is committed and shared; the diff.ansible-vault.textconv config is local per-clone, so teammates must opt in themselves. That is deliberate — it should be a conscious choice, not something the repository turns on for you.

🎯 Interview questions — Alternatives

Q. When would you use something other than Ansible Vault?

When you need rotation, an audit trail, per-user access control, or dynamic short-lived credentials — none of which Vault provides.

HashiCorp Vault for dynamic credentials and fine-grained policy; AWS Secrets Manager or SSM Parameter Store when you are already on AWS and want IAM control with CloudTrail auditing; SOPS when you want to keep secrets in Git but need reviewable diffs and KMS-based key management.

A useful middle step: keep Ansible Vault for the content, but fetch the vault password itself from a real secrets manager via a client script.

Q. What happens when someone leaves the team?

Rekeying the vault does not revoke their access to history — they can still decrypt any commit made while they had the password.

Real revocation means rotating the underlying secrets: the database passwords, the API tokens, the certificates. Then rekey the vault as hygiene.

This is one of the strongest practical arguments for a real secrets manager, where revoking a person's access is a policy change rather than a rotation of every credential they ever saw.

Q. What is the performance consideration with a secrets-manager lookup?

Lookups run on the control node, and a lookup inside a task that runs per host executes once per host — so a 200-host play makes 200 API calls to Secrets Manager, which is slow and may hit rate limits.

Fetch once with run_once: true plus delegate_to: localhost, set_fact the result, and read it from hostvars on the other hosts. With no_log: true throughout.

Q. How can encrypted files be made reviewable?

Either use encrypt_string so only the values are ciphertext and the file structure stays diffable, or configure a git textconv filter — git config diff.ansible-vault.textconv "ansible-vault view" with a .gitattributes entry — so git diff renders the plaintext locally for anyone who already has the password.

The caveat: the committed file is unchanged and still encrypted, but plaintext now appears in local git diff output, so it can reach a screen share or terminal recording. .gitattributes is shared; the textconv config is per-clone, so it stays an opt-in choice.

SOPS solves this properly by encrypting only values, leaving structure and keys visible by design.


Part E · Putting it together

E1 · Production practice

HabitWhy
The indirection pattern — readable vars.yml, encrypted vault.ymlReviewers see which secrets exist without decrypting; the backing store can be swapped later
Prefix vaulted variables with vault_grep -r vault_ enumerates every secret the project consumes
no_log: true on every task that touches a secret, written at the timeVault encrypts at rest only; the decrypted value reaches logs otherwise
loop_control: label: on loops over credential dictionariesAnsible echoes the full dict per iteration, passwords included
Vault IDs per environmentOne password for everything means dev access implies production access
Password file 0600 in $HOME, never in the repo; path in ansible.cfg is fineCommitting the path is useful; committing the file is a breach
In CI, write the password to a temp file — never a CLI argumentArguments appear in the process list, shell history and job logs
A client script fetching the password from a real secrets managerNothing on disk, IAM-controlled, audited, rotatable in one place
Never ansible-vault decrypt a working filePlaintext on disk plus git add -A equals a committed secret
Rotate the underlying secrets when someone leaves, not just the vault passwordRekeying does not protect history — old commits stay decryptable
Add *.vault-pass, .vault_pass*, *.pem, *.key to .gitignoreDefence in depth against a careless git add -A

E2 · Capstone exercise

Attempt this without looking anything up. It exercises the indirection pattern, vault IDs, leak prevention and environment separation together.

Brief. Secure the multi-environment repository from Module 06:

  1. Production and staging secrets encrypted with different passwords
  2. Playbooks must reference db_password, never vault_db_password
  3. A reviewer must be able to see which secrets exist without decrypting anything
  4. No task may print a secret, including under -vvv
  5. ansible.cfg configured so neither environment needs extra flags
  6. .gitignore preventing an accidental commit of a password file
  7. A single command must prove no plaintext secret exists anywhere in the repository
Model answer — attempt it first, then click
plain text
inventories/
  production/group_vars/all/
    vars.yml       readable   -> db_password: "{{ vault_db_password }}"
    vault.yml      encrypted with the prod vault id
  staging/group_vars/all/
    vars.yml       readable
    vault.yml      encrypted with the staging vault id
bash
# Requirement 1 - two identities, two passwords
echo 'prodpass'    > ~/.vault-prod    && chmod 600 ~/.vault-prod
echo 'stagingpass' > ~/.vault-staging && chmod 600 ~/.vault-staging

ansible-vault create --vault-id prod@~/.vault-prod \
  inventories/production/group_vars/all/vault.yml
ansible-vault create --vault-id staging@~/.vault-staging \
  inventories/staging/group_vars/all/vault.yml
yaml
# inventories/production/group_vars/all/vars.yml
# Requirements 2 and 3 - readable, shows WHAT exists, not the values
---
db_host: db01.prod.example.com
db_port: 5432
db_user: appuser
db_password: "{{ vault_db_password }}"
api_token: "{{ vault_api_token }}"
plain text
# ansible.cfg  -- requirement 5
[defaults]
vault_identity_list = prod@~/.vault-prod, staging@~/.vault-staging
yaml
# playbooks/site.yml  -- requirement 4
---
- hosts: all
  tasks:
    - name: Configure the database connection
      ansible.builtin.template:
        src: db.conf.j2
        dest: /etc/app/db.conf
        mode: "0640"
      no_log: true

    - name: Create application database users
      community.postgresql.postgresql_user:
        name: "{{ item.name }}"
        password: "{{ item.password }}"
      loop: "{{ db_users }}"
      loop_control:
        label: "{{ item.name }}"
      no_log: true
plain text
# .gitignore  -- requirement 6
.vault-*
*.vault-pass
.vault_pass*
*.pem
*.key
bash
# Requirement 7 - prove no plaintext secret is committed
git grep -nE '(password|token|secret|api_key)\s*:\s*[^"{ ]' -- \
  ':!*vault.yml' ':!*.md' || echo "CLEAN - no plaintext secrets found"

The six decisions that matter:

  1. vault_identity_list in ansible.cfg — requirement 5. Both identities are always available, Ansible matches each file to its label, and neither environment needs a flag.
  2. group_vars/all/ as a *directory* containing both files — Module 02 Part C2. Ansible merges every file in the directory, which is exactly how the readable/encrypted split works.
  3. Playbooks reference db_password, never vault_db_password — requirement 2. Swap to HashiCorp Vault later by editing one line in vars.yml.
  4. no_log: true on both tasks, plus loop_control: label: — requirement 4. The label alone is not enough for the postgresql_user task, because the module arguments contain the password.
  5. .vault-* in .gitignore matches the actual filenames chosen — a .gitignore full of patterns that match nothing is theatre.
  6. The git grep in requirement 7 excludes vault.yml deliberately: those files are supposed to contain secrets, and they are ciphertext. What you are hunting for is a plaintext password that leaked into a readable file.

Verify:

bash
ansible-playbook -i inventories/staging    playbooks/site.yml --list-tasks
ansible-playbook -i inventories/production playbooks/site.yml --check
ansible-vault view --vault-id prod@~/.vault-prod \
  inventories/production/group_vars/all/vault.yml
ansible-vault view --vault-id staging@~/.vault-staging \
  inventories/production/group_vars/all/vault.yml   # MUST fail - wrong password
git status --short                                  # no ~/.vault-* files

That second view failing is the proof that requirement 1 actually holds.


E3 · Command reference — everything from this module

Commands from Module 07. ⭐ marks genuinely daily-use.

Creating and editing

bash
ansible-vault create secrets.yml                       # new encrypted file in $EDITOR
ansible-vault create --vault-id prod@prompt secrets.yml  # ⭐ under a specific identity
ansible-vault edit secrets.yml                         # ⭐ decrypt, edit, re-encrypt
ansible-vault view secrets.yml                         # ⭐ read without modifying
ansible-vault encrypt existing.yml                     # encrypt a plaintext file
ansible-vault encrypt file1.yml file2.yml              # several at once
ansible-vault decrypt secrets.yml                      # ⚠️ permanent - rarely correct
ansible-vault rekey secrets.yml                        # ⭐ change the password
ansible-vault rekey --new-vault-id prod@~/.new secrets.yml

Single-value encryption

bash
ansible-vault encrypt_string 'SuperSecret123' --name 'vault_db_password'   # ⭐
ansible-vault encrypt_string --stdin-name 'vault_api_token'                # ⭐ no shell history
echo -n 'secret' | ansible-vault encrypt_string --stdin-name 'vault_x'
ansible-vault encrypt_string --vault-id prod@~/.vault-prod 'val' --name 'vault_x'

Running playbooks

bash
ansible-playbook site.yml --ask-vault-pass                        # prompt
ansible-playbook site.yml --vault-password-file ~/.vault-pass     # ⭐
ansible-playbook site.yml --vault-id prod@~/.vault-prod           # ⭐ labelled
ansible-playbook site.yml --vault-id dev@~/.vault-dev \
                         --vault-id prod@~/.vault-prod            # ⭐ several
export ANSIBLE_VAULT_PASSWORD_FILE=~/.vault-pass                  # ⭐
ansible-vault view --vault-id prod@~/.vault-prod group_vars/prod/vault.yml

Config keys

plain text
[defaults]
vault_password_file = ~/.ansible-vault-pass                       # ⭐ single password
vault_identity_list = dev@~/.vault-dev, prod@~/.vault-prod        # ⭐ several

Auditing and hygiene

bash
# ⭐ find plaintext secrets that should be vaulted
git grep -nE '(password|passwd|secret|token|api_key)\s*:\s*[^"{ ]' -- ':!*vault.yml'

# ⭐ list every encrypted file in the repo
grep -rl 'ANSIBLE_VAULT;1.1' . --include='*.yml'

# ⭐ list every secret the project consumes, without decrypting
grep -rhoE 'vault_[a-z0-9_]+' --include='vars.yml' . | sort -u

# make vault files diffable locally (opt-in, per clone)
git config --local diff.ansible-vault.textconv "ansible-vault view"
echo 'group_vars/**/vault.yml diff=ansible-vault' >> .gitattributes

# check permissions on password files
ls -l ~/.vault-* && stat -c '%a %n' ~/.vault-*

Runtime lookups from a real secrets manager

yaml
- ansible.builtin.set_fact:
    db_password: "{{ lookup('community.hashi_vault.hashi_vault',
                     'secret=secret/data/prod/db:value') }}"
  no_log: true
  run_once: true                # ⭐ one API call, not one per host
  delegate_to: localhost

- ansible.builtin.set_fact:
    db_password: "{{ lookup('amazon.aws.aws_secret', 'prod/db/password') }}"
  no_log: true
The two-command secret audit, worth running on any repository you inherit:
bash
grep -rl 'ANSIBLE_VAULT;1.1' . --include='*.yml'       # what IS encrypted
git grep -nE '(password|token|secret)\s*:\s*[^"{ ]' -- ':!*vault.yml'   # what should be

If the second command returns anything, you have found a plaintext credential in version control — and the correct response is to rotate that credential, not merely to encrypt the file, because the history still contains it.


E4 · Official documentation

LinkCovers
Protecting sensitive data with Ansible VaultThe whole guide — concepts, formats, vault IDs
Encrypting contentWhole files versus encrypt_string, the !vault inline tag
Managing vault passwordsVault IDs, password files, client scripts
ansible-vault CLIEvery subcommand and flag
Keeping secrets out of logs — no_logThe official answer on run-time leakage
community.hashi_vault collectionHashiCorp Vault lookups and plugins
amazon.aws.aws_secret lookupAWS Secrets Manager at run time
Mozilla SOPSThe diffable alternative to whole-file Vault encryption

E5 · Self-assessment

1. What does Vault protect against, and what does it not?

It protects against someone reading your repository — AES256 encryption at rest.

It does not protect against anyone who can run your playbooks, it has no rotation, no audit log and no per-user access control, secrets are plaintext in memory at run time, and rekeying does not stop old commits from being decryptable by anyone who ever had the password.

2. Describe the indirection pattern and its three benefits.

A readable vars.yml mapping db_password: "{{ vault_db_password }}", and an encrypted vault.yml defining only vault_-prefixed variables.

Benefits: reviewers see which secrets exist without decrypting; playbooks never reference vault_* so the backing store can be swapped by editing one line; and grep vault_ enumerates every secret the project consumes.

3. Why does encrypt_string make review possible where whole-file encryption does not?

Only the value is ciphertext, so the file's structure and its non-secret keys stay readable and git diff shows exactly which secret changed. Whole-file encryption makes every change look like the entire file was rewritten.

4. What are vault IDs and why do they matter?

Labelled passwords — --vault-id prod@~/.vault-prod — letting different files use different passwords with Ansible matching each file to its label.

Without them, one password decrypts everything, so anyone who can deploy to dev can read production secrets.

5. Name three ways a secret leaks despite Vault working correctly.

Task output echoing module arguments, the item echo on a loop over credential dictionaries, and -vvv printing full module arguments.

Fixes: no_log: true, loop_control: label:, and not routinely running production at high verbosity.

6. Why should the vault password never be a command-line argument in CI?

It appears in the process list, in shell history, and frequently in job logs. Write it to a 0600 temp file and use --vault-password-file, removing it in an after_script that runs even on failure — or use a client script so it is never written to disk at all.

7. What is a vault password client script?

An executable given as the vault-id source, which prints the password to stdout and exits 0. It can fetch the password from AWS Secrets Manager or HashiCorp Vault at run time, authenticated by the runner's IAM role — so no password file exists on disk and rotation happens in one place.

8. Someone leaves the team. What actually needs to happen?

Rotate the underlying secrets — database passwords, API tokens, certificates. Rekeying the vault alone does not revoke access to history; they can still decrypt any commit made while they held the password.

Then rekey the vault as hygiene. This limitation is a primary argument for a real secrets manager, where revocation is a policy change.

9. When would you move off Ansible Vault, and to what?

When you need rotation, audit trails, per-user access or dynamic short-lived credentials. HashiCorp Vault for dynamic credentials and policy; AWS Secrets Manager or SSM if you are on AWS and want IAM control with CloudTrail; SOPS if you want to stay in Git but need diffable, KMS-managed encryption.

The pragmatic middle step: keep Vault for the content, fetch the vault password from a real secrets manager via a client script.

10. What is the performance trap with secrets-manager lookups?

Lookups run on the control node and execute once per host, so a 200-host play makes 200 API calls — slow and liable to hit rate limits.

Fetch once with run_once: true and delegate_to: localhost, set_fact it, and read from hostvars elsewhere. With no_log: true throughout.


Next — Module 08 · Execution Strategies & Performance at Scale.

serial, strategy, forks, async, throttle and fact caching — making Ansible fast and safe on hundreds of hosts rather than five.

📚 Sources for the interview questions

Behaviour verified against the current official Ansible Vault guide.

Question selection cross-referenced against publicly published 2026 Ansible interview question sets:

Answers were rewritten and deepened rather than reproduced — published versions are usually correct but shallow, and the added operational detail is what differentiates a candidate in the room.

Spotted a mistake or want something added? Send me a note.