Infrastructure

Stop Encrypting Entire Files with Ansible Vault. Use Vault Strings Instead.

15 min readBy Joseph Edmonds

Ansible Vault is the built-in answer to a real problem: you need secrets in your infrastructure code, and you can't commit plaintext passwords to git. But the way most teams use Vault, encrypting entire files, creates more problems than it solves. Since Ansible 2.3, there's been a better option: encrypting individual variable values with ansible-vault encrypt_string. After years of managing Ansible across production environments, I'm convinced that vault strings are the correct default and that file-level encryption should be treated as a legacy pattern.

Two Approaches to the Same Problem

Ansible Vault offers two distinct encryption strategies. The difference between them isn't cosmetic. It fundamentally changes how you work with secrets across your entire development lifecycle.

File-Level Encryption

The original approach. You run ansible-vault encrypt secrets.yml and the entire file becomes an encrypted blob. Every byte of content, including variable names, values, comments, and formatting, is replaced with ciphertext. The file header identifies it as vault-encrypted, and everything below that header is opaque.

Here's what a vault-encrypted file actually looks like in your repository:

$ANSIBLE_VAULT;1.1;AES256
36353031653464376538653338663731313066653839383139656138313334326638
62316535623834653238393064306536653565366131633064643539643830633365
31393865323135353332363336653432626233356339313035323665643465336261
63653261343139393132653134633739313535343935376365393762363137363936
38343462613864663965393662333063363665303635393362303431383537303038
35633937653562623464613930666332653766393563633035313534303934363264
61383661336365623165646264633633366533653338633738363031363366363639
30376164656261623561636135353432633462636234353230383466376439376461
38653638323336366163383337636435316366393766323030316133333462356465
33386262333432653261633632633633363833363034623234396235336530376565

That is your variables file. Good luck reviewing it.

Variable-Level Encryption (Vault Strings)

Introduced in Ansible 2.3, encrypt_string encrypts individual values while leaving variable names in plaintext. The encrypted value is embedded directly in your YAML using the !vault | tag. Only the sensitive data is encrypted. The structure, the keys, and any non-sensitive values remain perfectly readable.

# group_vars/production/main.yml
---
app_name: my-application
app_environment: production
app_debug: false
app_port: 8080

db_host: db-prod-primary.internal
db_port: 5432
db_name: app_production
db_user: app_service
db_password: !vault |
    $ANSIBLE_VAULT;1.1;AES256
    61626364656667686970716b6c6d6e6f70717273747576
    77787980818283848586878889909192939495969798
    99303132333435363738394041424344454647484950

api_secret_key: !vault |
    $ANSIBLE_VAULT;1.1;AES256
    31323334353637383930313233343536373839303132
    33343536373839303132333435363738393031323334
    35363738393031323334353637383930313233343536

redis_host: redis-prod.internal
redis_port: 6379

Look at the difference. You can see exactly what this file configures. You know the database host, the port, the application settings. The only things you can't see are the actual password and the API key, which is precisely the security boundary you want.

The Case Against File-Level Encryption

File-level encryption isn't just less convenient than vault strings. It actively harms four aspects of modern infrastructure development: safety, reviewability, searchability, and AI-assisted workflows.

The Decrypt-Edit-Re-encrypt Workflow Is Dangerous

To edit a vault-encrypted file, the standard workflow is:

# Step 1: Decrypt the file
ansible-vault decrypt group_vars/production/vault.yml

# Step 2: Edit it
vim group_vars/production/vault.yml

# Step 3: Re-encrypt it
ansible-vault encrypt group_vars/production/vault.yml

If step 3 fails, is interrupted, or is simply forgotten, you've got plaintext secrets sitting in your working directory. One careless git add -A and those secrets are in your commit history forever. Yes, you can use ansible-vault edit to combine these steps, and yes, you can set up pre-commit hooks to catch unencrypted vault files. But these are guardrails bolted onto a fundamentally fragile process. The underlying workflow requires you to temporarily put secrets into plaintext. Any process that relies on "don't forget to re-encrypt" is a process that will eventually fail.

Secrets committed to git are there forever

Deleting a file or overwriting a value does not remove it from git history. Anyone with access to the repository can recover every version of every file ever committed. If a plaintext secret hits a commit, even briefly, the only safe remediation is to rotate that secret immediately. Tools like git filter-branch or BFG Repo-Cleaner can rewrite history, but they require force-pushing to every remote and every clone. On a public repository, you must assume the secret has already been scraped. There is no undo.

Pre-commit hooks are often cited as the solution here. They're a band-aid. They catch the mistake after it's already happened in the working directory. They don't prevent the plaintext from existing in the first place. And they only work if every developer on the team has them installed and hasn't bypassed them with --no-verify.

Completely Opaque in Git

When someone changes a variable in a vault-encrypted file and opens a pull request, here's what the reviewer sees:

$ git diff HEAD~1 -- group_vars/production/vault.yml
diff --git a/group_vars/production/vault.yml b/group_vars/production/vault.yml
index 3a7b2c1..8f4e9d2 100644
--- a/group_vars/production/vault.yml
+++ b/group_vars/production/vault.yml
@@ -1,10 +1,10 @@
 $ANSIBLE_VAULT;1.1;AES256
-36353031653464376538653338663731313066653839383139656138313334326638
-62316535623834653238393064306536653565366131633064643539643830633365
-31393865323135353332363336653432626233356339313035323665643465336261
+39323134653938373635343231393837363534333231393837363534333231393837
+36353433323139383736353433323139383736353433323139383736353433323139
+38373635343332313938373635343332313938373635343332313938373635343332

What changed? A password? An API key? A database hostname? A comment? You've got absolutely no idea. Code review is impossible. The reviewer has to either trust the author blindly or decrypt the file locally, diff the plaintext, and hope nothing else changed that they missed.

Git's textconv feature can be configured to decrypt vault files for local diffs, but it only works in the CLI. It doesn't work on GitHub, GitLab, or any web-based PR review interface. Since most teams review pull requests in their browser, textconv solves the problem in exactly the place where nobody is looking.

Merge conflicts are even worse. Two engineers change different variables in the same vault file, and git can't merge encrypted blobs. The resolution process is: decrypt both versions, perform the three-way merge on plaintext, then re-encrypt. You're back to the dangerous decrypt-edit-re-encrypt dance, but now with added merge complexity.

Unsearchable

You can't grep for anything inside a vault-encrypted file. Variable names are hidden alongside their values. If you need to find where db_password is defined across your inventory, you have to decrypt every vault file first. The alternative is maintaining a separate, unencrypted reference file that lists the variable names. That's the official Ansible recommendation, and it's an admission that full-file encryption breaks basic discoverability.

Opaque to AI Tooling

This point didn't matter five years ago, but it matters enormously now. LLM-based coding assistants, whether Claude, Copilot, or anything else, are increasingly part of the infrastructure engineering workflow. They can review Ansible playbooks, suggest improvements, catch misconfigurations, and help refactor variable structures.

But they can't do any of this if the variables file is an encrypted blob. A vault-encrypted file is a solid wall to any AI tool. It can't see the variable names, can't understand the structure, and can't offer any meaningful assistance. You've eliminated an entire category of tooling from your workflow for zero additional security benefit over vault strings.

The Case for Vault Strings

Vault strings solve every one of these problems by placing the encryption boundary exactly where it belongs: around the secret values, and nowhere else.

Plaintext Keys, Encrypted Values

This is the core ergonomic win. Variable names stay in plaintext because variable names aren't secrets. When was the last time you needed to search your codebase for a password by its value? Never. You search for db_password or api_secret_key. You search by key name, and vault strings let you do exactly that.

# This works perfectly with vault strings
$ grep -r "db_password" group_vars/
group_vars/production/main.yml:db_password: !vault |
group_vars/staging/main.yml:db_password: !vault |

# This tells you nothing with file-level encryption
$ grep -r "db_password" group_vars/
# (no output — the variable name is encrypted too)

Values Stay Encrypted Throughout Development

With vault strings, sensitive values are never decrypted during development, during git operations, or during code review. They're only decrypted at Ansible runtime, when a playbook actually needs them. There's no decrypt-edit-re-encrypt cycle. There's no window where plaintext secrets exist in your working directory.

To change a vault string, you generate a new encrypted value and paste it in:

# Encrypt a new value
$ ansible-vault encrypt_string 'new-super-secret-password' --name 'db_password'
Encryption successful
db_password: !vault |
    $ANSIBLE_VAULT;1.1;AES256
    35363738393031323334353637383930313233343536373839303132333435363738
    39303132333435363738393031323334353637383930313233343536373839303132
    33343536373839303132333435363738393031323334353637383930313233343536

You copy that output, replace the old encrypted block in your YAML file, and commit. At no point did the old secret exist in plaintext in your working directory. At no point was any file fully decrypted. The safety improvement isn't marginal. It eliminates an entire class of accidental exposure.

Git Diffs That Actually Tell You Something

When a vault string changes in a pull request, the diff is genuinely useful:

$ git diff HEAD~1 -- group_vars/production/main.yml
diff --git a/group_vars/production/main.yml b/group_vars/production/main.yml
index 7c3a1b2..9d5e4f6 100644
--- a/group_vars/production/main.yml
+++ b/group_vars/production/main.yml
@@ -12,9 +12,9 @@
 db_name: app_production
 db_user: app_service
 db_password: !vault |
-    $ANSIBLE_VAULT;1.1;AES256
-    61626364656667686970716b6c6d6e6f70717273747576
-    77787980818283848586878889909192939495969798
+    $ANSIBLE_VAULT;1.1;AES256
+    39323134653938373635343231393837363534333231
+    39383736353433323139383736353433323139383736

You can't see the new password, and you don't need to. What the diff tells you is: "the db_password variable was changed, nothing else was modified." That's exactly the information a reviewer needs. They can see what changed conceptually, verify that no other variables were accidentally modified, and approve the PR with confidence.

Compatible with AI-Assisted Development

An LLM reading a vault-string file can see the complete structure of your infrastructure variables. It knows what secrets exist, how they are named, which services they belong to, and how they relate to each other. It can suggest renaming db_pass to db_password for consistency. It can identify that you have a redis_host but no corresponding redis_password. It can help you refactor your variable hierarchy across environments.

The encryption boundary is exactly where it should be. The AI can reason about structure and naming without ever seeing a single secret value. That's a meaningful improvement to your workflow, and you get it for free just by choosing vault strings over file encryption.

Encrypting Strings in Practice

The ansible-vault encrypt_string command has a few forms worth knowing about.

Basic Usage

# Encrypt a string with a password prompt
ansible-vault encrypt_string 'my-secret-value' --name 'variable_name'

# Encrypt from stdin (avoids the secret appearing in shell history)
echo -n 'my-secret-value' | ansible-vault encrypt_string --stdin-name 'variable_name'

# Encrypt using a password file
ansible-vault encrypt_string \
    --vault-password-file ~/.vault_pass \
    'my-secret-value' \
    --name 'variable_name'

A word of caution: passing the secret directly on the command line leaves it in your shell history. For production use, prefer the --stdin-name approach or use a password file.

Vault IDs for Multi-Environment Setups

Ansible 2.4 introduced vault IDs, which let you use different encryption passwords for different environments. This is essential for any setup where staging and production secrets shouldn't share an encryption key.

# Encrypt with a vault ID
ansible-vault encrypt_string \
    --vault-id production@prompt \
    'prod-db-password' \
    --name 'db_password'

# The output includes the vault ID in the header
db_password: !vault |
    $ANSIBLE_VAULT;1.2;AES256;production
    35363738393031323334353637383930313233343536373839303132333435363738
    39303132333435363738393031323334353637383930313233343536373839303132

# Decrypt at runtime with the matching vault ID
ansible-playbook site.yml \
    --vault-id production@~/.vault_pass_prod \
    --vault-id staging@~/.vault_pass_staging

Notice the header changes from $ANSIBLE_VAULT;1.1;AES256 to $ANSIBLE_VAULT;1.2;AES256;production. The vault ID label is appended, so Ansible can match the correct password at runtime.

Configuring Vault Passwords in ansible.cfg

For team workflows, configure vault password sources in ansible.cfg so that nobody has to remember command-line flags:

# ansible.cfg
[defaults]
vault_identity_list = production@~/.vault_pass_prod, staging@~/.vault_pass_staging

With this in place, ansible-playbook site.yml picks up the correct passwords automatically.

What About the Official vars/vault Separation Pattern?

The official Ansible documentation recommends an alternative pattern for keeping variable names visible while using file-level encryption. The idea is to split each group into two files:

group_vars/
  production/
    vars.yml          # Unencrypted — references vault_ prefixed variables
    vault.yml         # Fully encrypted — contains actual secret values

In vars.yml:

# group_vars/production/vars.yml (unencrypted)
db_password: "{{ vault_db_password }}"
api_secret_key: "{{ vault_api_secret_key }}"

In vault.yml (before encryption):

# group_vars/production/vault.yml (encrypted with ansible-vault encrypt)
vault_db_password: "actual-secret-password"
vault_api_secret_key: "actual-api-key-value"

This pattern exists precisely because full-file encryption breaks discoverability. It's Ansible's official admission that encrypting entire files hides too much. But look at the overhead: you now maintain two files per group, with a naming convention (vault_ prefix) and Jinja2 indirection for every single secret. Every secret requires a variable in vars.yml that references a variable in vault.yml. Add a new secret and you have to update both files. Rename a variable and you have to update both files.

Vault strings eliminate this indirection entirely. One file, one variable, one place to look. The variable name is visible because it is not encrypted. The value is encrypted because it is a secret. No duplication, no indirection, no vault_ prefix convention to remember.

The Rekey Trade-off (and How to Solve It)

The one genuine limitation of vault strings that people raise is that ansible-vault rekey doesn't work with them.

With file-level encryption, rekeying is a single command:

# Rekey an entire encrypted file
ansible-vault rekey group_vars/production/vault.yml

With vault strings, there's no built-in rekey command. To rotate your vault password, you have to re-encrypt each individual string with the new password. For a handful of secrets this is a minor inconvenience. For a large inventory with dozens of encrypted strings across many files, it would be genuinely tedious to do by hand.

The key word there is "would be." This is a solved problem. The LongTermSupport/ansible-role-vault-scripts Ansible role includes a rekeyVaultFile.bash script that automates the entire process. It reads each encrypted variable from a file, decrypts it with the old key, re-encrypts it with the new key, and writes a new file. You run one command per file and the rotation is done:

# Rekey all vault files in a single environment
bash shellscripts/vault/rekeyVaultFile.bash \
    dev \
    ./vault-pass-dev.secret \
    dev \
    ./vault-pass-dev.secret-new \
    environment/dev/group_vars/all/vault-*

The script creates new files prefixed with new_ so you can verify them before replacing the originals. It isn't destructive by default.

Here's my honest assessment: I've rotated vault passwords perhaps three or four times across all the Ansible-managed infrastructure I've worked with. It's not a frequent operation. The safety and ergonomic benefits of vault strings are felt every single day, on every commit, every PR review, every grep, and every time an AI assistant reads your inventory. Trading a slightly more involved (but infrequent and fully scriptable) rekey process for a dramatically better daily workflow is an easy decision.

Recommended File Structure

With vault strings, your group_vars structure becomes simpler, not more complicated. You don't need the vars/vault file split because there are no fully-encrypted files to hide from:

inventory/
  group_vars/
    all/
      common.yml              # Shared non-secret config
    production/
      main.yml                # All production vars, secrets as vault strings
      database.yml            # DB-specific vars, passwords as vault strings
    staging/
      main.yml                # All staging vars, secrets as vault strings
      database.yml            # DB-specific vars, passwords as vault strings
  host_vars/
    web-prod-01/
      main.yml                # Host-specific vars with inline vault strings

Each file is self-contained. Secrets live alongside the configuration they belong to, encrypted at the value level. There's no indirection layer, no vault_ prefix convention, and no separate encrypted file to keep in sync.

A complete example of a production variables file:

# inventory/group_vars/production/main.yml
---
# Application
app_name: my-application
app_environment: production
app_debug: false
app_log_level: warning
app_domain: app.example.com

# Database
db_host: db-prod-primary.internal
db_port: 5432
db_name: app_production
db_user: app_service
db_password: !vault |
    $ANSIBLE_VAULT;1.2;AES256;production
    35363738393031323334353637383930313233343536373839
    30313233343536373839303132333435363738393031323334
    35363738393031323334353637383930313233343536373839

# Redis
redis_host: redis-prod.internal
redis_port: 6379
redis_password: !vault |
    $ANSIBLE_VAULT;1.2;AES256;production
    39323134653938373635343231393837363534333231393837
    36353433323139383736353433323139383736353433323139
    38373635343332313938373635343332313938373635343332

# External APIs
stripe_api_key: !vault |
    $ANSIBLE_VAULT;1.2;AES256;production
    61626364656637383930313233343536373839303132333435
    36373839303132333435363738393031323334353637383930
    31323334353637383930313233343536373839303132333435

monitoring_webhook_url: https://hooks.slack.com/services/T00/B00/xxxxx
backup_s3_bucket: my-app-backups-prod
backup_retention_days: 30

Readable, greppable, reviewable, AI-parseable, and secure. That is the entire point.

Tooling: ansible-role-vault-scripts

If you're serious about using vault strings at scale, you need tooling. The LongTermSupport/ansible-role-vault-scripts role is a collection of shell scripts that automate every common vault string operation. It's packaged as an Ansible role so you can pin a version in your requirements.yml and integrate it directly into your project.

What the role provides:

  • Generate vault secrets: creates long random vault password files with generateVaultSecret.bash
  • Create vaulted passwords: generates a secure random password, encrypts it, and writes it to a variable in one step with createVaultedPassword.bash
  • Encrypt arbitrary strings: wraps ansible-vault encrypt_string with environment-aware defaults via createVaultedString.bash
  • Generate vaulted SSH key pairs: creates password-protected private/public keys, encrypts all three values (passphrase, private key, public key) as vault strings with createVaultedSshKeyPair.bash
  • Generate vaulted deploy keys: passwordless SSH keys for read-only deploy access with createVaultedSshDeployKeyPair.bash
  • Generate vaulted SSL client certificates: certificate authority and client certificates, all values encrypted as vault strings with createVaultedSslClientCertificateAndAuth.bash
  • Rekey vault files: the rekeyVaultFile.bash script solves the rekey limitation entirely, decrypting with the old key and re-encrypting with the new one
  • Dump secrets: view decrypted values without ever writing plaintext to disk with dumpGroupSecrets.bash
  • Multi-environment support: all scripts support environment selection (dev, staging, production) and auto-detect the environment from file paths

Installation is straightforward. Add it to your requirements.yml:

# requirements.yml
- src: https://github.com/LongTermSupport/ansible-role-vault-scripts
  scm: git
  name: lts.vault-scripts
  version: master

Then symlink the scripts into your project:

ansible-galaxy install --force --keep-scm-meta \
    --role-file=requirements.yml \
    --roles-path=roles

mkdir -p shellscripts
ln -s ../roles/lts.vault-scripts/shellscripts/ shellscripts/vault

Once installed, creating a new encrypted password is a single command:

# Generate a random password, encrypt it, and write it to a vars file
bash shellscripts/vault/createVaultedPassword.bash \
    vault_db_password \
    ./environment/prod/group_vars/all/vault_database.yml \
    prod

Generating vaulted SSH key pairs is equally simple:

# Generate key pair with encrypted passphrase, private key, and public key
bash shellscripts/vault/createVaultedSshKeyPair.bash \
    vault_deploy \
    [email protected] \
    ./environment/prod/group_vars/all/vault_ssh_keys.yml \
    prod

The point of this tooling is that vault strings shouldn't feel like extra work. With the right scripts, they're actually less work than file-level encryption because you never have to think about decrypt/edit/re-encrypt cycles at all.

A Practical Migration Path

If your team currently uses file-level encryption and wants to migrate, the process is straightforward:

# 1. Decrypt the existing vault file
ansible-vault decrypt group_vars/production/vault.yml

# 2. For each secret variable, encrypt the value as a string
ansible-vault encrypt_string \
    --vault-id production@prompt \
    'the-actual-secret-value' \
    --name 'db_password'

# 3. Build your new combined vars file with vault strings inline
# 4. Remove the old vault.yml and vars.yml pair
# 5. Commit the new structure

If you have the vault-scripts role installed, you can streamline step 2 using createVaultedString.bash which handles environment detection, vault password lookup, and output formatting automatically.

If you were using the vars/vault separation pattern, you can merge both files into a single file, replacing the Jinja2 references with inline vault strings. The result is fewer files, less indirection, and a clearer structure.

Do this one environment at a time. Start with a development or staging environment where the stakes are low, verify that playbook execution works identically, then move to production.

Summary: Why Vault Strings Win

The comparison isn't close.

Concern File Encryption Vault Strings
Plaintext exposure risk Secrets exist in plaintext during editing Secrets are never decrypted outside runtime
Git diffs Meaningless encrypted blob Key names visible, values encrypted
Pull request review Impossible without local decryption Reviewer sees which variable changed
Merge conflicts Decrypt both sides, merge, re-encrypt Standard YAML merge (keys are plaintext)
Searchability Cannot grep for variable names Full grep and IDE search support
AI/LLM compatibility Completely opaque Full structural visibility
File structure Requires vars/vault split or loses discoverability Single file per group, self-contained
Password rotation (rekey) Built-in ansible-vault rekey Scriptable with vault-scripts tooling

File-level encryption used to win on password rotation convenience, but with proper tooling that advantage disappears. Vault strings win on safety, reviewability, searchability, tooling compatibility, and structural simplicity.

The encrypt_string feature has been available since Ansible 2.3. It's not new, it's not experimental, and it's not a niche pattern. It's the way secrets should be managed in Ansible, and I'd encourage any team still encrypting entire files to make the switch.