Vault-backed secret injection for Docker Compose. kvc itself never
writes plaintext to disk. It pipes the resolved compose YAML to
docker compose via stdin and passes .env values through subprocess
environment. Once values reach Docker, plaintext does land on disk
in /var/lib/docker/containers/<id>/config.v2.json (the same place
docker inspect reads from); see the threat model for the full picture.
Before: credentials sit on disk in plaintext:
# .env
DB_PASSWORD=hunter2
API_TOKEN=sk-abc123After: only placeholders on disk; secrets live in Vault:
# .env
DB_PASSWORD=@@kv/db#password@@
API_TOKEN=@@kv/api#token@@kvc up # auth → fetch → substitute → pipe to docker composeAnyone who gets hold of your files, malware, an accidental git push, a
backup, only ever sees the @@…@@ placeholder strings. The actual secrets
never leave Vault.
The main threat kvc defends against is plaintext secrets sitting on disk at rest.
In a typical Docker Compose setup, credentials live in plaintext in
compose YAML and (more commonly) a sibling .env file. Those files
get backed up, synced to cloud drives, accidentally committed to git,
included in disk images, and (most acutely) scraped by info-stealing
malware that specifically hunts for files named .env. Once
exfiltrated, every credential in the file is compromised, and rotating
across many stacks is painful and incomplete.
With kvc, those files contain only placeholders like
"@@kv/db#password@@". Plaintext exists only in Vault (encrypted
at rest, audited, ACL'd), briefly in the kvc and docker compose
processes during up, and (once the stack is running) in Docker's
own container state files (see threat model).
Concretely, this defeats:
- Info-stealer malware exfiltrating
.envfiles. RedLine, Lumma, Stealc and similar families specifically grep for.envfiles and ship them off. With kvc, what they ship is a list of@@…@@strings, useless without your Vault credentials. - Accidental git commits. A
.envor compose file pushed to a public repo (one of the leading sources of credential leaks on GitHub) leaks placeholders, not credentials. - Backups, cloud sync, and disk forensics.
.envfiles routinely end up in iCloud / Dropbox / Google Drive folders, nightly backup tarballs, rsync mirrors, and the disks of stolen or discarded laptops. None of that contains plaintext anymore. - Misconfigured file permissions. A
.envthat's mode 644, or sitting in a world-readable directory, exposes only placeholders to other users on the host. - Secret rotation blast radius. When a credential is compromised,
you rotate once in Vault and every stack that references it picks up
the new value on the next
kvc up. Nofind . -name .env | xargs sedacross your homelab. - Centralized audit trail. Vault logs every secret read, so you have a record of what got pulled, when, and by whom (a Vault feature kvc routes through).
What kvc is not. Once a stack is running, docker inspect shows
the resolved env in plaintext to anyone with docker access. An
attacker already running code as you on this host can use the cached
Vault token, or wait for you to type your password, to resolve
placeholders themselves. kvc raises the bar against passive disk
attacks (file copies, backups, malware that scrapes files); it doesn't
replace endpoint security. Read the full threat model
before relying on it.
kvc up
└── locates the compose file (compose.yaml/.yml or docker-compose.yaml/.yml in cwd; or -f <path>)
└── reads it (and ./.env next to it, if present)
└── finds @@<mount>/<path>#<key>@@ placeholders in both
└── auths to Vault (userpass; token cached in the kernel keyring)
└── GETs each secret from <mount>/data/<path> and reads the named <key>
└── substitutes docker-compose.yml values in memory
└── resolves .env values in memory and exposes them as subprocess env
└── pipes the substituted YAML to `docker compose --env-file /dev/null -f - up -d`
└── exits. Plaintext is gone, no temp files.
go install github.com/fish-not-phish/kvc@latestThe binary lands in $GOPATH/bin (usually ~/go/bin). Make sure that's on your PATH:
export PATH="$PATH:$(go env GOPATH)/bin"Add that line to your ~/.bashrc or ~/.zshrc to make it permanent.
git clone https://github.com/fish-not-phish/kvc.git kvc && cd kvc
chmod +x install.sh
./install.sh # installs to /usr/local/bin
PREFIX=$HOME/.local ./install.sh # or somewhere user-writableOr via the Makefile:
make build # produces ./kvc
sudo make installkvc init
# Vault address (e.g. https://vault.example.com:8200): <your vault url>
# Vault userpass username: <your username>This writes ~/.config/kvc/config.toml:
vault_addr = "https://vault.example.com:8200"
username = "<your username>"
cache_tokens = true
keyring_max_ttl = "8h"In any value position in your compose file, write
"@@<mount>/<path>#<key>@@":
<mount>: KV v2 mount (the first slash-segment).<path>: path under that mount (everything between the first slash and#).<key>: which field of the secret to read.
services:
app:
image: example/app
environment:
DB_USER: "@@<mount>/<path>#<key>@@"
DB_PASSWORD: "@@<mount>/<path>#<key>@@"
API_TOKEN: "@@<mount>/<path>#<key>@@"The mount is inline, so a single compose file can pull from multiple Vault mounts. ACL design is your problem; write a policy per mount that grants the deploying user read on the paths it needs.
Always quote placeholders. @ is a reserved YAML indicator, so a bare
KEY: @@foo@@ is technically invalid YAML. kvc up survives because it
substitutes before handing the file to docker compose, but anything else
that reads the raw file (docker compose down -f file, docker compose config, yamllint, an IDE's YAML check) will reject it. Wrap every
placeholder in double quotes ("@@foo@@"); single quotes work too. kvc check warns when it finds unquoted placeholders.
Placeholders must occupy a whole YAML value. password: "@@foo@@" is
fine. Embedded forms like args: ["--pwd=@@foo@@"] are not. Substitution
emits a quoted YAML scalar, so the placeholder needs to be a complete value,
not a substring.
Many compose stacks pull values from a .env file via ${VAR}
interpolation rather than inlining environment: in the compose file.
kvc handles this:
- By default, if a
.envfile exists in the same directory as the compose file,kvcreads it. - Override with
--env-file PATH. Disable entirely with--no-env-file.
| Pattern | Works? |
|---|---|
Inline "@@…@@" in compose.yml environment: |
✓ |
Compose ${VAR} interpolation reading from .env |
✓ |
Placeholders in .env values |
✓ |
Plain (non-secret) values in .env passed through |
✓ |
env_file: .env directive in compose.yml |
✗ |
Recursive ${VAR} expansion within .env values |
✗ |
The ✗ cases are not bugs. They're places where docker compose owns the
parsing path and kvc can't intercept it without rewriting the YAML.
Workarounds for each are in the .env caveats subsection below.
Placeholders in .env values are resolved in memory just like the compose
file, then exposed to docker compose as subprocess environment so
${VAR} interpolation in the YAML picks them up. The on-disk .env is
never rewritten; placeholders stay as @@…@@ at rest.
# .env
DB_USER=app
DB_PASSWORD=@@<mount>/<path>#<key>@@
# docker-compose.yml
services:
app:
image: example/app
environment:
DB_USER: ${DB_USER}
DB_PASSWORD: ${DB_PASSWORD}kvc up resolves the placeholder, sets DB_PASSWORD=<secret> on the
docker subprocess, and compose's ${DB_PASSWORD} interpolation picks it up.
kvc check validates placeholders across both files and reports any
that won't resolve.
- No recursive
${VAR}expansion within.envvalues.kvcparses.envline-by-line and substitutes@@…@@placeholders only. If you reference one.envkey from another (FOO=${BAR}_x), that won't expand;kvcpasses the literal string to docker. Move such logic to compose.yml'senvironment:instead, where docker handles it. env_file:directive in compose.yml is unsupported with placeholders. When compose hasenv_file: .env, docker reads the file directly into the container's environment. The subprocess-env trick can't intercept it, and the container would receive literal@@…@@strings. Use${VAR}interpolation instead.kvcalways passes--env-file /dev/nullto docker compose so docker doesn't auto-load the on-disk.env(which still contains placeholders). All env vars come from the subprocess env thatkvccontrols.
kvc init Set up ~/.config/kvc/config.toml.
kvc up [-f FILE] Auth, fetch secrets, pipe to `docker compose up -d`.
[--env-file PATH] Override .env auto-detection.
[--no-env-file] Skip .env entirely.
[--no-cache] Skip the kernel-keyring token cache.
[--vault-addr URL] Vault address (overrides config).
[--username NAME] Vault username (overrides config).
[--password-stdin] Read password from stdin; implies --no-cache.
kvc down [-f FILE] `docker compose down` (no secrets needed).
kvc check [-f FILE] Verify every placeholder resolves in Vault. No deploy.
[--env-file PATH] Same .env semantics as `up`.
[--no-env-file]
[--vault-addr URL]
[--username NAME]
[--password-stdin]
kvc logout Revoke the cached token and clear the keyring entry.
--vault-addr and --username each override the corresponding config value.
When both are supplied, no config file is required — useful in CI environments
where writing a config file is inconvenient.
If -f is omitted, kvc searches the cwd in this order and uses the
first that exists: compose.yaml, compose.yml, docker-compose.yaml,
docker-compose.yml. (Same precedence docker compose itself uses.)
kvc works in CI/CD pipelines via --password-stdin. The typical setup for
homelabbers is a self-hosted runner on the Docker host, the runner and
Docker are on the same machine, so kvc up deploys locally without any SSH
hop.
One-time runner setup:
- Register a self-hosted runner on your Docker host (GitHub, Gitea, and GitLab all have a registration script under repo → Settings → Actions/CI → Runners).
- Note the label/tag assigned to your runner, the workflow's
runs-on(GitHub/Gitea) ortags:(GitLab) must match it exactly, or jobs will sit in "Waiting" forever. - Ensure the runner user is in the
dockergroup. - Add three secrets to your repo:
VAULT_ADDR,VAULT_USER,VAULT_PASSWORD.
Your stack repo only needs the compose file(s) and optional .env, no
kvc source. Copy a workflow template from
test/runner/ into your repo:
| Platform | Template | Destination |
|---|---|---|
| Gitea | test/runner/gitea-deploy.yml |
.gitea/workflows/deploy.yml |
| GitHub | test/runner/github-deploy.yml |
.github/workflows/deploy.yml |
| GitLab | test/runner/gitlab-ci.yml |
.gitlab-ci.yml |
The workflow installs Go and kvc automatically if they aren't already on the runner, then runs:
# GitHub / Gitea (${{ secrets.NAME }} syntax)
echo "${{ secrets.VAULT_PASSWORD }}" | kvc check \
--vault-addr "${{ secrets.VAULT_ADDR }}" \
--username "${{ secrets.VAULT_USER }}" \
--password-stdin --no-cache
echo "${{ secrets.VAULT_PASSWORD }}" | kvc up \
--vault-addr "${{ secrets.VAULT_ADDR }}" \
--username "${{ secrets.VAULT_USER }}" \
--password-stdin --no-cache
# GitLab ($VARIABLE_NAME syntax)
echo "$VAULT_PASSWORD" | kvc up \
--vault-addr "$VAULT_ADDR" \
--username "$VAULT_USER" \
--password-stdin --no-cacheNo config file needed on the runner, --vault-addr and --username supply
everything kvc init would normally write.
By default, after a successful userpass login kvc stores the Vault
token in the Linux kernel keyring (@s session keyring) with TTL =
min(token_lease, keyring_max_ttl). Subsequent invocations skip the password
prompt until the entry expires or the session ends.
Set cache_tokens = false in the config (or pass --no-cache) to re-prompt
on every invocation. Only the bounded, revocable token is ever cached.
Never the password.
kvc logout revokes the token server-side and removes it from the keyring.
What kvc itself protects:
- No plaintext written by
kvc. The resolved compose YAML goes todocker compose -f -via stdin; resolved.envvalues go via subprocess environment.kvcitself never writes a temp file. - No password in argv, env, or shell history. TTY read only.
- Vault token lives in the kernel keyring only (
@ssession keyring), never on disk, dies on session end orkvc logout. - No long-running daemon, no docker socket exposure, no API surface.
What Docker does on its own (and kvc can't prevent):
/var/lib/docker/containers/<id>/config.v2.jsonstores the full env array in plaintext. This is wheredocker inspectreads from. Anyone with root, anyone in the host'sdockergroup, or anyone who can read/var/lib/docker/sees the secrets. This is plain Docker behavior; the only way to keep secrets out of this file is Swarm-modedocker secrets mounted at/run/secrets/<name>(a tmpfs), which the target audience doesn't run. If you need this, run Swarm.- Container logs (
/var/lib/docker/containers/<id>/<id>-json.log) capture stdout/stderr. If a containerized app prints its env at startup or in error messages, secrets end up there.kvccan't fix this. /proc/<pid>/environexposes a running container's env to root on the host for the lifetime of the process.
Other residual risks:
- Bootstrap. If Vault/OpenBao is itself deployed via Docker Compose on
this host,
kvccannot deploy it (chicken/egg). Vault's own compose stack must use a plain.envfile. - Process memory. Plaintext exists briefly in the
kvcanddocker composeprocesses during stack-up. We zero the password buffer, the secrets map, and the rendered YAML buffer after use, but a root-on-host attacker with/procaccess can read it during the deploy window. We don'tmlock, so under memory pressure secrets could page out to swap. - Core dumps. If
kvc,docker compose, ordockerdcrashes and the system is configured to dump core, plaintext can land in/var/lib/systemd/coredump/(or wherever your system writes cores). Disable core dumps for the relevant binaries if this is in your threat model.
kvc reads from KV v2 mounts and expects each placeholder to name a
specific key inside a secret:
<mount>/<path> <key1> = "...", <key2> = "...", ...
Group related credentials at one path (e.g. an app's DB user + password +
DSN under the same secret) so they rotate together and share one ACL
boundary. If you'd rather have one secret per credential, just store one
key per path. kvc doesn't care, but every placeholder still has
to name its key explicitly with #<key>.
- Linux (kernel keyring). macOS / Windows are not supported in v1.
- Docker with the Compose v2 plugin (
docker compose, notdocker-compose). - HashiCorp Vault or OpenBao with userpass auth and a KV v2 mount.