← all writing

How I set up Vault to stop storing secrets in plain text

Two weeks before this, I'd run a security audit on my Docker infrastructure. The number-one critical finding was depressingly familiar: secrets everywhere in plain text. AWS access keys in compose files. VPN private keys in environment files. Database passwords sitting in configs that anyone with SSH access could read. API tokens scattered across twenty-something container stacks with no access control, no audit trail, and no rotation.

I knew it was bad. Like most technical debt, it had gotten worse one docker-compose.yaml at a time. Every new service needed a password or an API key. Every time, I pasted it into the env file and moved on. Two years of that and the env files held every credential my infrastructure depended on.

This is the post about how I fixed it. Not perfectly, not all at once, but enough that the audit's number-one finding is no longer true.

#What Vault actually does

The simplest version is: Vault is a locked safe for credentials. Instead of writing passwords on sticky notes and taping them to the filing cabinet — which is what env files are — you put them in the safe. When an application needs a password, it identifies itself, the safe hands the secret over, and the application uses it without ever storing it permanently.

But it's more than encrypted storage.

Access control. Each application can only read its own secrets. If one container is compromised, the attacker sees one set of credentials — not the whole environment. The blast radius of any single compromise drops by an order of magnitude.

Audit logging. Every read is logged. Who asked for what, when, and whether they were authorized. "When was this database password last accessed and by whom?" becomes a single query instead of an unanswerable question.

Encryption at rest. The data on disk is encrypted. Even if someone reaches the underlying filesystem, they can't read the secrets without the unseal key — which doesn't live on the same machine.

Dynamic secrets. This is the long-term play I haven't fully adopted yet. Instead of a static database password that lives forever, Vault can mint a temporary credential that expires in twenty-four hours. If it leaks, it expires before it matters. No human-in-the-loop rotation needed.

#Deployment is the easy part

Vault runs as a single Docker container. The compose file is small enough to memorize: image, restart policy, volumes for data and config, port mapping. The config file points at a file-based storage backend, enables the web UI, and sets up the listener. Twenty lines of YAML and HCL.

There are two production-vs-homelab compromises worth being explicit about.

disable_mlock = true in the config. By default Vault tries to lock its memory pages so secrets can't get swapped to disk. That requires a kernel capability Docker doesn't grant by default. The right answer in production is to grant IPC_LOCK to the container or run Vault outside Docker entirely. The right answer in a homelab is to disable mlock, accept the risk that a stressed host might page out a secret, and move on. I disabled it.

tls_disable = 1 because Vault sits behind a reverse proxy that handles TLS termination. In production you'd run TLS end to end. At home, behind one trusted reverse proxy on a wired LAN, the simpler setup is fine.

Neither of these is something I'd do at work. Both are conscious tradeoffs that match the threat model. Listing them in a config file with comments next to them is the right kind of "future me will thank past me" documentation.

#Shamir's Secret Sharing, in homelab dosage

The first time Vault starts, it's sealed. The encrypted storage exists, but Vault has no way to decrypt it until you provide the master key. The master key is generated during initialization — and split into shares using Shamir's Secret Sharing.

The idea is elegant. The master key is a secret you can't keep in one place — if it's on disk, an attacker who reaches the disk has it. So you split it into N pieces such that any K of them can reconstruct it, and you distribute the pieces to different people or different secure locations. Production deployments use 5 shares with a threshold of 3, so any three out of five officers can unseal the vault, but no one person can.

In a single-operator homelab, you don't have multiple officers. I configured 1 share and a threshold of 1 — which is mathematically equivalent to not splitting it at all — and stored the unseal key in a password manager and the recovery copy in a fireproof safe. That's fine for the threat model. But it taught me what the production version looks like and why it's structured that way, which is the actual reason I deployed Vault in a homelab. The concepts I run at home are the concepts that scale.

#Organizing secrets

I created a KV v2 secrets engine — Key-Value, version 2, where every write preserves the previous value as a version history. "What was this password three weeks ago when the integration last worked?" becomes answerable.

The path layout follows the natural ownership boundaries:

clients/
├── client-a/
│   └── cloudflare    → API token, zone ID
├── side-business/
│   └── terraform-cloud  → API token, expiration
└── homelab/
    └── ...

Each tenant has its own subtree. Policies (which I haven't fully built out yet) will eventually scope each application to its own subtree, so the worst case of any single compromise is "that tenant's secrets, period." Path-as-policy-boundary is one of those design choices that pays back forever.

#The first real use, the same day

The point of deploying Vault was to use it. I had a Cloudflare API token I needed to feed Terraform that afternoon. The old way was the bad way:

echo 'cloudflare_api_token = "cf_xxxxx..."' > terraform.tfvars
# hope nobody commits this to git

The new way is the actual point of the project:

# Pull the token from Vault at runtime
export CF_TOKEN=$(curl -s \
  --header "X-Vault-Token: $VAULT_TOKEN" \
  $VAULT_ADDR/v1/clients/data/client-a/cloudflare \
  | python3 -c "import sys,json; print(json.load(sys.stdin)['data']['data']['api_token'])")

# Pass it to Terraform as a regular variable
terraform apply -var="cloudflare_api_token=$CF_TOKEN"

The token exists as an environment variable for the duration of one command. It's never written to a file. It's never committed. It lives in encrypted storage, gets pulled on demand, gets used, gets discarded.

This is the fundamental shift. Credentials become something you request, not something you store. The application asks Vault for what it needs, uses it, and moves on. If you rotate the token, you update Vault — once — and every system that uses it gets the new value the next time it asks. There's no env-file-grep, no compose-file-restart, no "did I update it everywhere" anxiety.

#The things that broke first

None of these were serious. All of them are the kind of practical experience you only get by deploying.

Port conflict. Vault defaults to port 8200. I had another service already there. Vault refused to start, the error message was clear, I switched to 8201 and moved on. Five-second fix, one-line lesson: ss -tlnp | grep 8200 before deploying anything new.

Mlock failure. The disable_mlock = true thing I mentioned above. The error message at startup was helpful, the fix was documented in the first paragraph of every Vault-on-Docker tutorial, but it's the kind of thing that catches you the first time.

File ownership. Docker created the data and config volumes owned by root, which I didn't notice until I tried to scp an updated config and got "Permission denied." chown fixed it. The lesson is generic to Docker volume management — not Vault-specific — but it's the kind of friction that stops a five-minute task for ten minutes the first time you hit it.

#What I learned

The hardest part is changing the habit. The deployment was thirty minutes. The technical setup is straightforward. The hard part is breaking the muscle memory of pasting secrets into env files. Every time I set up something new, my first instinct is still to put the API key directly in the compose file. Vault requires an extra step — store the secret first, then reference it. That extra step is the entire security improvement. If you want the system to work, the friction has to be lower than the temptation to skip it. For me, that means a one-line bash function (vault-get path/to/secret) that makes the "pull from Vault" path shorter than the "paste into a file" path.

Secrets management is table stakes. Almost every senior infrastructure job posting at the comp band I care about mentions secrets management. It's not an advanced topic; it's baseline operational security. The fact that most homelabs (and many production environments) still store secrets in plain text doesn't make it acceptable. It makes it a common vulnerability. There is no compelling reason in 2026 to operate any environment with API tokens in env files.

Start simple, grow later. I started with KV v2 — store a key, read a key. The next layers are AppRole authentication (so containers authenticate with a role instead of using the root token), policies (so each service can only read its own subtree), audit logging (so every access is recorded), and dynamic database credentials (so passwords auto-rotate). All of those are real improvements. None of them are urgent the day you deploy Vault. "Encrypted, centralized, not in Git" is already most of the value. Everything else is optimization.

It connects to everything. Vault isn't a standalone tool. Within hours of deploying it, I was using it from Terraform, from a deployment script, from a backup tool. Once you have a single source of truth for credentials, every tool you write next pulls from it instead of taking a parameter. The ergonomics improve with every consumer.

#What's next

I'm not still running the same Vault deployment six months later. After about a month of running this setup, I started experimenting with Bitwarden Secrets Manager — an alternative I'd been curious about because the rest of my password management already runs on Bitwarden, and the seam between "the family's passwords" and "the homelab's API tokens" started to feel artificial. That migration is its own post, with its own gotchas, and I'm not done with the comparison yet.

But the lessons here travel. Whatever secrets manager you pick, the architectural shift is the same: credentials become something you request from a managed system instead of something you write into a file and forget. Vault was the right starting point for me because the concepts in it are the concepts that show up at scale. Whether I run Vault forever, or migrate everything to Bitwarden Secrets Manager, or end up at HCP Vault running in someone else's cloud — the discipline is the same.

The audit's number-one finding was wrong about my infrastructure within a week of running it. That was the actual goal.


← all writing