Encrypting .env files with OpenBao Transit

September 25, 2026

Encrypting .env files with OpenBao Transit

September 25, 2026
Encrypting .env files with OpenBao Transit

TL;DR

  • A .env file is a plaintext secret that ends up in repositories, container images and laptop backups. OpenBao Transit lets you commit it encrypted with every value stored as vault:v1:... ciphertext.
  • Transit is encryption as a service. The key never leaves OpenBao so the application asks OpenBao to decrypt and never holds anything it could leak later.
  • Split the permissions so CI gets an encrypt-only policy and the running application a decrypt-only one. Nobody needs both.
  • If the secret can simply live in OpenBao you do not need Transit at all. Keep it in the KV engine and put a reference in the file instead, which is what our favourite tools vals and vals-operator do.

A .env file is a plaintext secret that gets copied everywhere

For a long time I had a directory on my laptop with more .env files in it than I would like to admit. There was one per customer and project, each with the credentials I needed to do my job. They held pretty much anything from cloud provider keys to API keys for the many services we use. The disk was encrypted and I told myself that was enough. When I stopped being comfortable with it I started using gnupg to encrypt and decrypt them. The problem was that I often forgot to encrypt them again at the end of the day.

That is the real issue with .env files. The format is fine and every framework reads it. The problem is that the file is plaintext and gets copied everywhere, and .gitignore is the only thing standing between it and your commit history. When it does leak you usually find out from a scanner or a bill.

We already use OpenBao for dynamic database credentials, which we wrote about in Dynamic credentials with OpenBao and Vault. Not every secret can be dynamic though. A third party API key or a licence string is static by nature and for those we have two options. We either keep the value in OpenBao and put a reference to it in the file, or we encrypt the value and keep the ciphertext in the file. This post is mostly about the second one, which is what the Transit engine is for, but we will point out where the first is simpler.

Transit is encryption as a service, and the key never leaves OpenBao

Transit is encryption as a service. You create a named key inside OpenBao, send it plaintext and get ciphertext back or the other way round. The key material is never returned to the client. A compromised application can only decrypt what its policy allows and only for as long as its token lives, and every request lands in the audit log.

Setting it up takes two commands:

bao secrets enable transit
bao write -f transit/keys/myapp

Transit expects the plaintext base64 encoded, which is why you will see base64 in every example below.

Encrypt each value, not the whole file, so pull requests stay reviewable

We prefer to encrypt each value on its own rather than the whole file. That keeps the file readable so a reviewer can still see which variables changed in a pull request without seeing what they changed to.

Given a normal file:

# myapp configuration
DATABASE_URL=postgres://app:s3cr3t@db.example.com:5432/app
STRIPE_API_KEY=sk_test_not_a_real_key
LOG_LEVEL=info

The env-seal script walks it line by line and replaces each value with its ciphertext. The plaintext goes to bao on standard input (plaintext=-) so it never appears in the process list:

#!/usr/bin/env bash
# Usage: env-seal <key-name> < .env > .env.sealed
set -euo pipefail
key="${1:?usage: env-seal <key-name> < .env}"

while IFS= read -r line || [[ -n "$line" ]]; do
  if [[ -z "$line" || "$line" == \#* || "$line" != *=* || "${line#*=}" == vault:v* ]]; then
    printf '%s\n' "$line"
    continue
  fi
  ct=$(printf '%s' "${line#*=}" | base64 | tr -d '\n' |
       bao write -field=ciphertext "transit/encrypt/${key}" plaintext=-)
  printf '%s=%s\n' "${line%%=*}" "$ct"
done

The script is deliberately simple. It treats everything after the first = as the value, so it does not strip quotes or an export prefix. Keep the file to plain KEY=value lines. It also encrypts every value, including harmless ones like LOG_LEVEL, so nobody has to decide what counts as a secret.

The output is safe to commit:

# myapp configuration
DATABASE_URL=vault:v1:Vux8Ndin12BCwbNM47LB2pWt3zX2I03TlNopSosrOf0hwGjFwDmbWHg14LrEmWvXq0EFM3kt7pPqglrgaS4zrnnt1Tre8uP0Vw==
STRIPE_API_KEY=vault:v1:Uf68GHlv7gU/xRfG9PkQap5twV67Y8VdScweUF4fDW7iMgJ39tnLsuc210wbat4/dWY=
LOG_LEVEL=vault:v1:r4+fR7Yf7RO8SX+Jn5SuZvIzNxWKoqEPtwYDw5CCu58=

The v1 is the key version that produced it and it matters later when we rotate.

Decrypt at start-up and exec the application, with no code change

On the other side env-open reads the sealed file, decrypts each value, exports it and then execs your process. The application reads its environment exactly as it did before and needs no code change:

#!/usr/bin/env bash
# Decrypt a sealed .env file into the environment and exec the command.
# Usage: env-open <key-name> <file> -- <command> [args...]
set -euo pipefail
key="${1:?usage: env-open <key-name> <file> -- <command>}"
file="${2:?usage: env-open <key-name> <file> -- <command>}"
shift 2; [[ "${1:-}" == "--" ]] && shift

if [[ -z "${BAO_TOKEN:-}" && -n "${BAO_ROLE_ID:-}" ]]; then
  BAO_TOKEN=$(bao write -field=token auth/approle/login \
    role_id="$BAO_ROLE_ID" secret_id="$BAO_SECRET_ID")
  export BAO_TOKEN
  unset BAO_SECRET_ID
fi

while IFS= read -r line || [[ -n "$line" ]]; do
  [[ -z "$line" || "$line" == \#* || "$line" != *=* ]] && continue
  name="${line%%=*}"
  value="${line#*=}"
  if [[ "$value" == vault:v* ]]; then
    value=$(bao write -field=plaintext "transit/decrypt/${key}" \
            ciphertext="$value" | base64 --decode)
  fi
  export "$name=$value"
done < "$file"

exec "$@"

You can use it as a container entrypoint or in front of a local command:

env-open myapp .env.sealed -- ./myapp

The script makes one request per variable, which is fine for a typical .env file. For files with many variables, transit/decrypt also accepts a batch_input list so you can decrypt everything in a single request.

The application needs a token to decrypt with. For a VM or a CI runner AppRole is the usual choice, and the script logs in by itself when it finds BAO_ROLE_ID and BAO_SECRET_ID in the environment:

bao auth enable approle
bao write auth/approle/role/myapp \
  token_policies=myapp-decrypt token_ttl=15m token_max_ttl=1h secret_id_ttl=24h

On Kubernetes we use the Kubernetes auth method instead. It exchanges the pod's service account token for an OpenBao token, so you no longer have to worry about how the secret ID got there in the first place:

bao write -field=token auth/kubernetes/login role=myapp \
  jwt=@/var/run/secrets/kubernetes.io/serviceaccount/token

What this does not protect

Once the process starts the plaintext is in its environment and anyone who can read /proc/<pid>/environ as that user can read it too. What changes is everything before that moment. The repository, the image, the CI cache and the laptop backup now only ever hold ciphertext.

If the secret can live in OpenBao, reference it with vals instead

If env-open reminds you of vals exec that is not a coincidence. vals is one of our favourite tools and we use it all the time. It does the same job the other way round. The secret stays in the OpenBao KV engine and the file only holds a reference to it. vals reads YAML rather than the KEY=value format so the file looks like this:

# env.yaml
DATABASE_URL: ref+openbao://secret/myapp#/database_url
STRIPE_API_KEY: ref+openbao://secret/myapp#/stripe_api_key
LOG_LEVEL: info
bao kv put secret/myapp database_url='postgres://...' stripe_api_key='sk_test_...'
vals exec -f env.yaml -- ./myapp

There is nothing to encrypt and changing a secret is a bao kv put rather than a commit. Authentication works as above with BAO_TOKEN, AppRole or Kubernetes auth. On Kubernetes we go one step further with vals-operator. We wrote it to turn those same references into a Kubernetes Secret, keep it in sync and restart the workloads that use it when a value changes:

apiVersion: digitalis.io/v1
kind: ValsSecret
metadata:
  name: myapp
spec:
  name: myapp-env
  data:
    DATABASE_URL:
      ref: ref+openbao://secret/myapp#/database_url
  rollout:
    - kind: Deployment
      name: myapp

Split encrypt and decrypt into separate policies so nobody holds both

This is the main reason to use Transit rather than encrypting the whole file with a shared key. Encrypting and decrypting are separate paths so they can be separate policies:

bao policy write myapp-encrypt - <<'EOF'
path "transit/encrypt/myapp" {
  capabilities = ["update"]
}
path "transit/rewrap/myapp" {
  capabilities = ["update"]
}
EOF

bao policy write myapp-decrypt - <<'EOF'
path "transit/decrypt/myapp" {
  capabilities = ["update"]
}
EOF

Developers and the CI pipeline get myapp-encrypt. They can add or change a secret, rewrap existing values after a key rotation and commit the result, but if they try to decrypt they get permission denied. Only the running application holds myapp-decrypt. Use one key per application and environment because anyone with decrypt on a key can read everything that key has ever encrypted. You can split the vals approach the same way. A policy with only create and update on secret/data/myapp lets someone write a value they can never read back.

Rotate and rewrap the key without touching the application

Rotation adds a new key version and keeps the old ones:

bao write -f transit/keys/myapp/rotate

New encryptions now produce vault:v2:... while the v1 values in the file still decrypt, so nothing breaks. To move the file forward rewrap re-encrypts the ciphertext with the latest version entirely inside OpenBao. The plaintext never comes back to the client, which means a pipeline holding myapp-encrypt can do it with no decrypt permission at all:

bao write -field=ciphertext transit/rewrap/myapp ciphertext="vault:v1:..."

A loop like the one in env-seal can do this for every line. Once everything is on v2, stop OpenBao from decrypting anything older:

bao write transit/keys/myapp/config min_decryption_version=2

That setting can be lowered again by anyone allowed to change the key's config. To make an old leaked copy of the file permanently useless, trim the old versions so the key material itself is gone:

bao write transit/keys/myapp/trim min_available_version=2

You can also let OpenBao rotate the key on a schedule with auto_rotate_period=720h and rewrap in CI whenever you like.

The same engine handles field-level encryption, keyed hashes and signing

We see the same engine used for a few other jobs. Field-level encryption is the obvious one. The application encrypts a column such as a national insurance number before it reaches the database, so a database dump or a read replica only holds ciphertext.

transit/hmac/myapp gives you a keyed hash, which is useful when you need to look a value up or deduplicate it without storing it. With an ed25519 key transit/sign and transit/verify can sign build artefacts or webhooks without the signing key ever sitting on a build agent.

Default to vals, and use Transit only when data must live outside OpenBao

If the secret can simply live in OpenBao then keep it in the KV engine and use vals, or vals-operator on Kubernetes. That is simpler than anything else in this post and it is our default. Transit is for when the data has to live outside OpenBao. That could be a repository that travels somewhere OpenBao cannot be reached, a database column or a file that goes through someone else's pipeline.

Where the secret lives, and when to reach for each tool
ToolWhere the secret livesUse it for
vals / vals-operatorOpenBao KV engine. The file holds only a reference.The default, whenever the secret can live in OpenBao
OpenBao TransitCiphertext in the file. The key stays in OpenBao.Data that must live outside OpenBao: repositories, database columns, other people's pipelines
SOPSEncrypted file, and it can use Transit as its key backendTeams already using SOPS for YAML files
Sealed SecretsKubernetes Secret, decrypted by one cluster's controller by defaultKubernetes Secret objects only

Sealed Secrets only works for Kubernetes Secret objects and by default ties decryption to one cluster's controller. vals-operator covers the same ground without a per-cluster key.

SOPS is the closest alternative to Transit and it can use Transit as its key backend (sops --hc-vault-transit), so the two are not really competitors. If your team already uses SOPS for YAML files you can point it at OpenBao and get the same access control and audit trail. vals can read SOPS files too through ref+sops://. If all you have is a .env file and a shell then the scripts here are shorter than learning SOPS.

Everything here works on HashiCorp Vault too

Everything above works against HashiCorp Vault. Replace bao with vault, BAO_ADDR with VAULT_ADDR and ref+openbao:// with ref+vault:// in vals. We ran the same encrypt request with the Vault 1.17 CLI against the OpenBao server and got the same result because Transit has the same API in both. We have written about the licence and the differences between the two in Choosing a secrets storage: HashiCorp Vault vs OpenBao. There is more on how OpenBao is splitting into plugins in OpenBao's modular architecture and on moving off Enterprise in Migrating from HashiCorp Vault Enterprise to OpenBao or free Vault.

Next: the credentials on engineers' laptops should not exist at all

Transit and vals fix the application's .env file. They do not fix the other ones on an engineer's laptop full of AWS access keys and GCP service account JSON for every customer they work with, which is exactly where my own story started. Those should not be encrypted. They should not exist. In Secure access to AWS with OpenBao we show how to get short lived AWS credentials from OpenBao that live in a single terminal session and stop working at the end of the day. No more forgetting to encrypt anything before you log off.

Frequently asked questions

What is OpenBao Transit?

Transit is OpenBao's encryption-as-a-service secrets engine. You create a named key inside OpenBao, send it plaintext and get ciphertext back, or the other way round. The key material never leaves OpenBao, so a client can only encrypt or decrypt what its policy allows, for as long as its token lives, and every request is written to the audit log.

How do you safely commit a .env file to Git?

Encrypt each value with OpenBao Transit so the file holds vault:v1:... ciphertext instead of plaintext, then decrypt at start-up and exec the application. Encrypting each value rather than the whole file keeps the variable names visible, so reviewers can see which variables changed in a pull request without seeing the values.

Should I use Transit or the KV engine for application secrets?

Use the KV engine by default. If the secret can live in OpenBao, store it there and put a reference in the config file, which vals resolves at runtime and vals-operator syncs into a Kubernetes Secret. Use Transit when the data itself has to live outside OpenBao: a repository that travels where OpenBao cannot be reached, a database column, or a file passing through someone else's pipeline.

Can CI encrypt secrets without being able to decrypt them?

Yes. Transit's encrypt, decrypt and rewrap operations are separate API paths, so separate policies can grant them. Give developers and CI a policy covering transit/encrypt and transit/rewrap, and give only the running application a policy covering transit/decrypt. Use one key per application and environment, because decrypt on a key reads everything that key has ever encrypted.

How do you rotate an OpenBao Transit key without breaking the application?

Rotate the key to add a new version. Old ciphertext still decrypts, so nothing breaks. Rewrap each value to the latest version, which happens entirely inside OpenBao and needs no decrypt permission. Then set min_decryption_version to stop older versions decrypting, and trim the old versions with transit/keys/<name>/trim so a leaked copy of the old file becomes permanently useless.

Does this work with HashiCorp Vault?

Yes. Transit has the same API in OpenBao and Vault. Replace bao with vault, BAO_ADDR with VAULT_ADDR, and ref+openbao:// with ref+vault:// in vals.

Encrypting the file is the easy part

The real work is an OpenBao or Vault cluster that is highly available and unseals automatically. It needs backups proven by a restore, a connection to your identity provider and Kubernetes clusters, and policies someone has actually reviewed. That is what we build for customers, and we support it with 24x7 managed services.

If your .env files are still plaintext, get in touch at digitalis.io/contact.

References and related reading

Subscribe to newsletter

Subscribe to receive the latest blog posts to your inbox every week.

By subscribing you agree to with our Privacy Policy.
Thank you! Your submission has been received!
Oops! Something went wrong while submitting the form.

Ready to Transform 

Your Business?