Skip to main content
RunBook Academy

AnsibleXXI · Secrets ManagementSecrets management

Encrypting one variable, not the whole file

Intermediate⏱ ~22 minansible-core

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

Not yet marked complete on this device.

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.

Read-only / Safeencrypt one value under a vault ID
$ 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
        6664

That 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:

Read-only / Safeeverything visible except the value that must not be
# 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
        6339

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

Read-only / Safeit round-trips
$ ansible-playbook -i localhost, readvar.yml --vault-id prod@.prod-pass
ok: [localhost] => {
  "changed": false,
  "msg": "decrypted and matched"
}

PLAY RECAP ****************************************************
localhost   : ok=1  changed=0  unreachable=0  failed=0

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

Read-only / Safethree ways, worst to best
# 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:

Read-only / Safestraight into the vars file, nothing on the command line
$ printf 'REPLACE_ME' | ansible-vault encrypt_string --vault-id prod@.prod-pass --stdin-name 'db_password' >> main.yml
Reading 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.

Read-only / Safefile-level: fails before any task runs
$ 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
Read-only / Safeinline: the play starts, and fails at the point of use
$ 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=2

Read 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 requestnoyes, except the values
Fails when the password is missingat load, before any taskat first use of the variable
Rotating the vault passwordone rekey per filere-encrypt every value individually
Mixing secret and non-secret datadiscouraged; makes the whole file opaquethat is the point
Somebody adding a secret by accidentit is encrypted either waya plaintext value is visible in review
Suitable for large amounts of secret datayestedious 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:

Read-only / Safesplit by rate of change, not by secrecy
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 command

Inline 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

  1. Q1. What is the central argument for encrypt_string over encrypting the whole vars file?

  2. Q2. A play with an inline !vault variable is run without the vault password. What happens, as measured on 2.21.3?

  3. Q3. Which are accurate reasons to keep some material in a fully encrypted file rather than as inline values? Select all that apply.

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