AnsibleXXI · Secrets ManagementSecrets management
Encrypting one variable, not the whole file
What you'll learn
- Produce an inline vault value with encrypt_string without the secret entering shell history
- Make the reviewability argument for variable-level over file-level encryption
- Predict when each style fails - at load or at the point of use - and choose accordingly
- Avoid the rotation trap that a repository mixing both styles creates
Prerequisites
Verified against ansible-core 2.21.x · ansible (community package) 14.x · Python (controller) 3.12+ · ansible-lint 26.x · Molecule 26.x · Ubuntu 24.04 LTS · Debian 12 (Bookworm) · RHEL / Rocky / AlmaLinux 9.x · 2026-08-11
Encrypting a whole vars file is the obvious move and it has a cost that shows up in code review rather than in operations.
Here is a pull request against a fully encrypted file:
- 33366230663736616532653338336334323164646334383532303638653236
- 3134383233333737623436383336616135646131393934300a616162663064
+ 62643339373436616566346666306130666165363637326339653730613563
+ 3630333832306239650a663332643664316630346639373336666636393939
What changed? A password rotated? A new host added to a list? A production endpoint pointed at a test system? An entire credential set replaced?
The reviewer cannot tell. If they have the vault password they can decrypt both sides and diff them — which nobody does routinely, and which puts plaintext on their disk when they do. If they do not have it, they can only approve on trust.
A change that cannot be reviewed is a change that gets rubber-stamped. That is not a criticism of the reviewer; it is what the format leaves them.
encrypt_string
The alternative encrypts individual values, so the file stays readable and only the secrets are opaque.
$ ansible-vault encrypt_string --vault-id dev@.dev-pass 'REPLACE_ME' --name 'db_password'db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;dev
33366230663736616532653338336334323164646334383532303638653236366637366265643335
3134383233333737623436383336616135646131393934300a616162663064346263666264636165
62643339373436616566346666306130666165363637326339653730613563643265616365393631
3630333832306239650a663332643664316630346639373336666636393939333536613634396266
6664That output is valid YAML you paste straight into a vars file. !vault is
a YAML tag telling Ansible the scalar needs decrypting; the | is an
ordinary block scalar carrying the same envelope a vault file has, indented
under the key.
The result is a file a reviewer can actually read:
# inventory/production/group_vars/app/main.yml
app_name: billing
app_port: 8443
db_host: db1.example.com
db_user: billing_ro
db_password: !vault |
$ANSIBLE_VAULT;1.2;AES256;prod
33656432323139626338306532383365366262333432313935643735336539393735303161336162
3231356435333833396238353463326461386137313464390a386537383365353563363063366338
65626662383136346264366235393863316437393336646232323631383163353162383362353534
6161316335623666620a373636383934383135326433353862623030643837353335306163656462
6339Now the pull request tells a story. Changing db_host from db1 to db2
is visible. Changing app_port is visible. Only db_password is opaque,
and the fact that it is what changed is itself visible — which is often
all a reviewer needs to know.
Verified end to end on 2.21.3: the play reads that file and the value decrypts to the original string.
$ ansible-playbook -i localhost, readvar.yml --vault-id prod@.prod-passok: [localhost] => {
"changed": false,
"msg": "decrypted and matched"
}
PLAY RECAP ****************************************************
localhost : ok=1 changed=0 unreachable=0 failed=0Keep the secret out of your shell history
The form in the documentation puts the plaintext on the command line,
which puts it in ~/.bash_history and in the process table while it runs.
For a real credential, do not do that.
# Worst: the secret is now in shell history and was in the process table
ansible-vault encrypt_string --vault-id prod@prompt 'REPLACE_ME' --name 'db_password'
# Better: prompt for the value, and hide it while typing (the default)
ansible-vault encrypt_string --vault-id prod@prompt --prompt --name 'db_password'
# Best when the value comes from elsewhere: read it from stdin
generate-token | ansible-vault encrypt_string --vault-id prod@prompt \
--stdin-name 'db_password'--prompt reads the value interactively and does not echo it unless you
add --show-input. --stdin-name takes the value on standard input and
names it in one step, which is what to use in any pipeline where another
tool produces the credential.
The --stdin-name form was used to generate the file above:
$ printf 'REPLACE_ME' | ansible-vault encrypt_string --vault-id prod@.prod-pass --stdin-name 'db_password' >> main.ymlReading plaintext input from stdin. (ctrl-d to end input, twice if your content does not already have a newline)Note printf rather than echo: echo appends a newline, and a trailing
newline inside a credential is a genuinely miserable bug to chase — the
value looks right in every diagnostic and the far end rejects it.
The trade-off nobody mentions: when it fails
File-level and variable-level encryption fail at different moments, and the difference is measurable. Both runs below supply no vault password.
$ ansible-playbook -i localhost, readfile.yml; echo exit=$?[ERROR]: Invalid vars_files file '.../prod-secrets.yml':
Attempting to decrypt but no vault secrets found.
exit=1$ ansible-playbook -i localhost, readinline.yml; echo exit=$?TASK [A task that runs BEFORE anything touches the secret] ******
"msg": "reached task 1"
TASK [The task that uses it] ***********************************
[ERROR]: Task failed: Error rendering expression: Attempt to use
undecryptable variable: Attempting to decrypt but no vault secrets found.
fatal: [localhost]: FAILED! => ...
exit=2Read that carefully, because it is the real cost of the reviewability you gained.
File-level encryption fails fast. The vars file cannot be loaded, so the run stops at exit code 1 before a single task executes. Nothing was changed on any host.
Inline values fail lazily. They are decrypted when the expression is rendered, so the play starts, task 1 runs to completion, and the failure arrives at the first task that actually uses the variable — with exit code 2, the ordinary task-failure code.
At fleet scale that is a materially different outcome. A play whose secret is unavailable can now run its first fifteen tasks against 400 hosts before discovering the problem, leaving those hosts partially configured. The file-level version would have refused to start.
Choosing between the two styles
File-level (encrypt) | Variable-level (encrypt_string) | |
|---|---|---|
| Reviewable in a pull request | no | yes, except the values |
| Fails when the password is missing | at load, before any task | at first use of the variable |
| Rotating the vault password | one rekey per file | re-encrypt every value individually |
| Mixing secret and non-secret data | discouraged; makes the whole file opaque | that is the point |
| Somebody adding a secret by accident | it is encrypted either way | a plaintext value is visible in review |
| Suitable for large amounts of secret data | yes | tedious past a handful of values |
The last row on rotation is the one that decides it for many estates. A
rekey is one command per file. Re-encrypting inline values is one command
per value, and if you have them scattered across thirty group_vars files
you will not enjoy the afternoon.
A pragmatic layout that gets most of both:
inventory/production/group_vars/app/
main.yml # plaintext; ordinary settings, plus a few inline !vault
# values that change rarely and benefit from review context
vault.yml # fully encrypted; bulk credential material, rekeyable
# in one commandInline values for the handful of secrets whose context matters to a reviewer — the ones sitting next to the host and user they belong to. A file-level vault for the bulk material that is just a list of credentials and gains nothing from being read alongside anything.
Knowledge check
Knowledge check · 4 questions
Q1. What is the central argument for encrypt_string over encrypting the whole vars file?
Q2. A play with an inline !vault variable is run without the vault password. What happens, as measured on 2.21.3?
Q3. Which are accurate reasons to keep some material in a fully encrypted file rather than as inline values? Select all that apply.
Q4. Adding an assert in pre_tasks that the vault variable is defined and non-empty restores the fail-fast behaviour that file-level encryption provides.
Passing score: 75%. Answers are checked in this browser.