This is part 5 of the series "dotvault, Start to Finish: One Command from Your Laptop to CI, Your Team, and Your AI Agents." dotvault will be released free of charge when this series ends.

dotvault scan does not catch SMTP_PASSWORD=very-secret-mail-password-2026 in a .env file. It catches sk_live_… on the next line, AKIA… in a JavaScript file, ghp_… in a shell script and est_… in a GitHub Actions workflow, and it exits 1 so a pre-commit hook can stop the commit. That is the division of labour this part is about: .gitignore keeps .env out of git, scan keeps token-shaped strings out of your code, and dotvault push moves the files that do not split into keys and values, such as .npmrc and a kubeconfig, through the same channel as .env.

This is part 5 of 12. The takeaway: an .npmrc or a kubeconfig can be stored in one command and arrives on the next machine with the same pull or run that brings .env. Screens are from the production console and CLI 0.15.7, continuing with the demo-app project from parts 2 to 4.

The short version

  • A file that is not key=value pairs goes in with dotvault push <file>. .npmrc, a kubeconfig, a YAML configuration: text files you want kept whole. The import command and the Upload tab from part 4 do something else: they parse KEY=VALUE lines and register keys one by one.
  • Destination and pointer are registered with the file. --as .kube/config sets where the file lands on the receiving side; --pointer KUBECONFIG names the environment variable run sets to the temporary path. .npmrc got NPM_CONFIG_USERCONFIG without any option.
  • The receiving side uses dotvault pull or dotvault run. pull writes pushed files to their destination paths in the same run that writes .env. run writes them to a temporary directory, sets the pointer variables, and removes everything when the command exits. pull --file selects one key file such as .env.local.
  • The Files tab in the console lists, copies to another environment and deletes. Adding and downloading are done from the CLI.
  • dotvault scan . works without logging in. It looks for token-shaped strings (AWS access keys, GitHub tokens, dotvault service tokens and others: ten rules in all, listed in section 8), prints file, line, rule and a redacted excerpt, and exits 1 when it finds anything. Calling it from .git/hooks/pre-commit turns that into a blocked commit.
  • scan is not a guard for .env itself. A plain PASSWORD=value line is not matched. Keep .env out of git with .gitignore; use scan for secrets that leak into code, CI configuration and Dockerfiles.

Before you read on

  • Where we start. Part 4 left demo-app with .env (5 keys) and .env.local (1 key) in development. You can follow this part without having read it.
  • Environment. Production console, CLI 0.15.7, the same Node 20 Linux container as part 2. The "other machine" is a second directory in that container (machine-b) holding only project-config.json.
  • Terminal screens are recomposed. What the terminal printed is reproduced verbatim in a cleaner frame. The raw output is kept as is.
  • Demo account. Project names, file contents and token strings were created for this series. ghp_demo… and AKIAIOSFODNN7EXAMPLE do not work anywhere.
  • Vocabulary. A "key" is a name and a value, such as DATABASE_URL=…. A "file" is something registered whole with push. A "pointer" is the environment variable run sets to the temporary path of a file. An "exit code" is the number a command returns when it ends: 0 for success, anything else for failure.

1. Keys versus files

dotvault stores two kinds of things.

Key File
Unit one name and one value (DATABASE_URL=…) the whole content of a file
Written with + Add key / Upload in the console, set / import in the CLI push in the CLI only
Received with pull (written into .env etc.) / run (injected as environment variables) pull (written to the destination path) / run (written to a temp dir, pointer set)
Console Keys tab (view, write, delete) Files tab (list, copy to another env, delete; no add, no download)
History versions lists changes, rollback restores an earlier value each push adds one to the VERSION column in the console
Good for .env, .env.local, anything that is one setting per line .npmrc, kubeconfig, YAML configuration (text files)

.npmrc looks like KEY=VALUE, but a line such as //npm.pkg.github.com/:_authToken=… makes a poor key name. Pushing the file as one piece is the natural fit.

2. Pushing a file

From the demo-app directory, push .npmrc and ~/.kube/config.

Terminal screen for dotvault push (recomposed): cat .npmrc shows three lines (registry, @acme:registry, _authToken); dotvault push .npmrc prints "pushed .npmrc (v1) → .npmrc [run sets NPM_CONFIG_USERCONFIG] (project=demo-app, env=development)"; dotvault push ~/.kube/config --as .kube/config --pointer KUBECONFIG prints "pushed .kube/config (v1) → .kube/config [run sets KUBECONFIG]"; dotvault files lists .kube/config and .npmrc with the pointer names in parentheses.

Reading the output: pushed .npmrc (v1) → .npmrc means the file is stored under the name .npmrc as version 1 and will be written to .npmrc on the receiving side. [run sets NPM_CONFIG_USERCONFIG] means run will put the temporary path into that variable. No option was given for .npmrc; common files come with a pointer already assigned.

The kubeconfig got both options by hand. --as .kube/config puts it at .kube/config inside the project directory on the receiving side, and --pointer KUBECONFIG makes run set KUBECONFIG. For a file that lives outside the project, such as ~/.kube/config, spell out both options like this.

dotvault files lists the files registered for this environment as "name → destination (pointer)". Pushing the same file again produces v2.

3. The Files tab

The project page in the console has a Files tab. Before any push it is empty, with the line "Add one from the CLI with dotvault push".

Empty Files tab of demo-app (English UI): the environment switch with development selected, a "Copy all files in this env…" button on the right, and the text "No files in this env yet. Add one from the CLI with dotvault push".

Adding and downloading files are done from the CLI (push to add, pull or run to receive). After the push, the tab shows a table.

Files tab after pushing (English UI): development (3) selected; a table with FILE PATH, VERSION and UPDATED columns listing .kube/config (1), .npmrc (2) and cert.der (1); each row has Copy… and Delete buttons.

cert.der is a file I pushed to check that push is text only (section 9); it is deleted below. .npmrc is at version 2 because I added a line and pushed it again during the session.

"Copy all files in this env…" opens a dialog for copying every file to another environment.

The "Copy all files in development…" dialog (English UI): a Copy to selector showing staging, an unchecked "Overwrite files that already exist in staging" checkbox, and Cancel and Copy buttons.

Same idea as Compare envs in part 3: a file that already exists in the target is left alone unless the checkbox is ticked. I pressed Cancel here, because I did not want anything created in staging.

Delete on a row opens a browser confirmation ("Delete development/cert.der? This cannot be undone."). After OK, the line "Deleted development/cert.der." appears and the table has two rows.

Files tab after deleting cert.der (English UI): development (2) selected; the table lists .kube/config (1) and .npmrc (2).

4. Pulling on another machine

The receiving side: an empty directory with only project-config.json, and dotvault pull.

Terminal screen for dotvault pull in another directory (recomposed): after mkdir -p .kube, ls -la shows .kube and project-config.json; dotvault pull prints two lines for .env (5 keys) and .env.local (1 keys) and "2 files synced, 0 excluded, 6 keys total"; ls -la now also shows .env, .env.local and .npmrc with .npmrc at -rw-------; ls -la .kube shows the config written inside .kube; head -3 .kube/config shows apiVersion: v1, kind: Config, clusters:.

The pull output mentions .env and .env.local only, but ls -la shows .npmrc and .kube/config as well. Pushed files are written to their destination on every pull, and a newly written file gets the same -rw------- permissions as .env (owner read and write only).

The mkdir -p .kube at the top is explained in section 9. When the destination directory does not exist, pull writes none of the pushed files and prints the same output.

5. run: a temporary directory instead

pull leaves the files on the machine. For CI, or for a short task, run fits better.

Terminal screen for dotvault run (recomposed): dotvault run -- sh -c '…' prints KUBECONFIG=a temporary path under /tmp ending in config, an ls -la of that path (-rw-------, 279 bytes) and its first three lines (apiVersion: v1, kind: Config, clusters:); an ls -la of the same path after run says No such file or directory; a second run prints NPM_CONFIG_USERCONFIG=a temporary path under /tmp ending in .npmrc and npm config get @acme:registry returns https://npm.pkg.github.com/.

run writes every file that has a pointer into a temporary directory under /tmp, sets the pointer variable to that path and runs the command. When the command exits, the directory is removed. That is why the second ls -la says "No such file or directory".

For .npmrc, npm config get @acme:registry returns https://npm.pkg.github.com/: npm read the pushed .npmrc through NPM_CONFIG_USERCONFIG. There is no .npmrc in the project directory.

A file without a pointer (section 9's cert.der: no --pointer, no default) is written to its destination path for the duration of run and removed afterwards. If you want a file to stay on the machine, use pull.

6. scan: finding secrets before they are committed

The rest of this part is about not leaking rather than distributing. dotvault scan <path> reads the files and directories you name and looks for token-shaped strings. Nothing is sent to the server. It works without a login: after logout at the end of the session (whoami says "Not logged in."), scan . on the already-cleaned repository still ran and returned No secrets found. rather than an authentication error.

I built a small repository with three secrets: an AWS access key in src/s3.js, a GitHub token in deploy.sh and a dotvault service token in .github/workflows/ci.yml. The same AWS key also sits inside node_modules.

Terminal screen for dotvault scan (recomposed): find . -type f | sort lists five files including node_modules/some-dep/index.js; dotvault scan . prints three lines, .github/workflows/ci.yml:2 [dotvault-service-token], deploy.sh:2 [github-pat] and src/s3.js:3 [aws-access-key-id], each with a redacted excerpt such as est_...ue (72 chars), then "3 potential secret(s) found."; echo $? prints 1; dotvault scan src/ finds one; dotvault scan . in machine-b prints "No secrets found." and 0.

Each finding is "file:line [rule] description — excerpt". The excerpt is the first four characters, the last two and the length, so the secret itself never lands in your terminal scrollback or CI log. The key inside node_modules is not reported; a fixed list of directories is skipped (section 8).

The exit code is 1 when anything is found and 0 with No secrets found. otherwise. Several paths can be given (scan src/ tests/).

7. Stopping the commit

Before a commit, git runs .git/hooks/pre-commit if the file exists, and abandons the commit when that script exits with anything other than 0. scan exits 1 when it finds something, so placing it there is enough.

Terminal screen of a pre-commit hook blocking a commit (recomposed): cat .git/hooks/pre-commit shows #!/bin/sh and exec dotvault scan .; git add src deploy.sh .github .gitignore README.md && git commit prints the same three findings as section 6 and "3 potential secret(s) found."; git log --oneline says "fatal: your current branch 'main' does not have any commits yet"; after replacing the three literals with environment variables, git commit prints "No secrets found." and "[main (root-commit) f2748e0] add deploy script", "5 files changed, 11 insertions(+)".

The hook is two lines:

#!/bin/sh exec dotvault scan . 

The first commit prints the three findings and stops; git log confirms nothing was recorded. After replacing the values with process.env.AWS_ACCESS_KEY_ID and ${{ secrets.DOTVAULT_TOKEN }}, the second commit goes through.

This hook reads the whole working tree, not the staged diff, and that includes a .env excluded by .gitignore. If your local .env holds a value with a known shape, such as a live Stripe key, every commit stops. In that case, and in a large repository, narrow it: exec dotvault scan src/ .github/, leaving .env out. To share the hook with a team, put the same line in a hook manager such as lefthook or husky instead of .git/hooks.

8. What scan looks for, and what it does not

scan matches fixed patterns. CLI 0.15.7 ships ten rules.

Rule Pattern
aws-access-key-id AKIA + 16 characters
aws-secret-access-key a quoted 40-character string near the word aws
private-key -----BEGIN PRIVATE KEY-----, optionally with RSA / EC / OPENSSH / DSA / PGP (ENCRYPTED PRIVATE KEY is not matched)
github-pat ghp_ + 36 characters
github-fine-grained-pat github_pat_ + 60 or more characters
slack-token xoxb- and similar prefixes
stripe-secret-key sk_live_ + 24 or more characters
google-api-key AIza + 35 characters
dotvault-service-token est_ + 32 characters + . + 20 or more characters
generic-assignment secret / token / password / passwd / api_key (also api-key, apikey; case-insensitive), then = or :, then a quoted string of 16 or more characters with no whitespace

A fixed set of things is skipped.

Skipped Condition
Directories node_modules, .git, dist, build, .next, coverage, cdk.out, .turbo
Large files over 5 MB
Binary files a NUL byte in the first 8 KB

.gitignore is not consulted; directories are skipped by name.

Both tables come from reading the published CLI code; the directory, size and binary rules were also measured. A 6 MB text file and a 27-byte file containing a NUL byte, each with AKIAIOSFODNN7EXAMPLE inside, both come back as No secrets found.

Terminal screen of scan's limits (recomposed): ls -la shows big.txt (6000021 bytes) and blob.bin (27 bytes); dotvault scan big.txt blob.bin prints "No secrets found."; cat .env shows SMTP_PASSWORD=very-secret-mail-password-2026 and STRIPE_KEY=sk_live_demo…; cat docker-compose.yml shows POSTGRES_PASSWORD: "${POSTGRES_PASSWORD_FROM_ENV}"; dotvault scan . reports two findings, .env:2 [stripe-secret-key] and docker-compose.yml:4 [generic-assignment].

The second half of that screen is the point of this part. SMTP_PASSWORD=very-secret-mail-password-2026 in .env is not reported: generic-assignment only matches quoted values, and plain .env syntax has no quotes. sk_live_… on the next line is reported, because it has the Stripe shape.

So scan is not the tool that stops you committing .env. That is .gitignore's job. scan stops the other accident: a token written straight into code, CI configuration or a Dockerfile.

The reverse also happens. POSTGRES_PASSWORD: "${POSTGRES_PASSWORD_FROM_ENV}" in docker-compose.yml is a variable reference, not a value, but the quoted part is 16 characters or longer and it is reported. With this pattern, narrow the paths given to the pre-commit hook so the file is not included, or drop the quotes.

9. When it fails

The failures from this part, with the output and exit code for each.

Terminal screen of what happens when things go wrong (recomposed): rm -rf .kube .npmrc && dotvault pull prints the same two lines as section 4 and "2 files synced"; ls -la .npmrc .kube says No such file or directory for both; dotvault pull --file .kube/config prints "unexpected: Invalid env file name: ".kube/config"" and 99; dotvault pull --file .npmrc prints PROJECT_NOT_FOUND (server: No DDB secrets for .npmrc) and 5; dotvault push .yarnrc prints VALIDATION_ERROR (Could not read .yarnrc: ENOENT) and 6; dotvault files --env staging prints "No files synced."; dotvault scan nope/ prints "No secrets found." and 0.

Operation Output Exit code
pull with the destination directory (.kube) missing the two .env lines and "2 files synced"; neither .npmrc nor .kube/config is written 0
pull --file .kube/config unexpected: Invalid env file name 99
pull --file .npmrc PROJECT_NOT_FOUND (server: No DDB secrets for .npmrc) 5
push .yarnrc (file does not exist) VALIDATION_ERROR (Could not read … ENOENT) 6
files --env staging (environment with no files) No files synced. 0
scan nope/ (path does not exist) No secrets found. 0

The first row is why section 4 starts with mkdir -p .kube. If a destination includes a directory, create it on the receiving side first. Without it, none of the pushed files are written (.npmrc, which comes after .kube/config, is missing too). The "2" in "2 files synced" counts .env and .env.local; pushed files are not in that number, so check with ls that they arrived.

Rows two and three show pull --file used on pushed files. pull --file selects one key file such as .env.local. Pushed files arrive with pull (everything) or run.

The last row: a mistyped path returns "No secrets found." and 0. A pre-commit hook that runs scan src/ in a repository without src passes every time, so make sure the paths in the hook exist.

One more thing: push is for text files.

Terminal screen of pushing a binary file (recomposed): od -c cert.der shows 16 bytes starting 211 P N G; dotvault push cert.der prints "pushed cert.der (v1) → cert.der"; dotvault run -- sh -c 'od -c cert.der …' shows the first bytes changed to 357 277 275 P N G and 18 bytes; ls -la cert.der inside run shows 18 bytes.

The first byte of the PNG header (0x89) came back as the three-byte Unicode replacement character. push reads the file as a string, so a DER certificate cannot be distributed this way.

Next

Part 6 covers one place for a value that several projects share, and the binding that decides which project sees it, in the console and with dotvault global.

dotvault stores the secrets in your .env and config files server-side and hands them to any authorised developer, CI job or AI agent with a single command (https://dotvault.io). It will be released free of charge when this series ends, and the steps in these articles are the steps you will use on day one.

If you find this useful, please like and follow.

Our products are listed at https://www.eln.ne.jp/products

EarthLink Network builds its own products to run the whole company on AI — each one born from a need along the way. See the full index of what we build:

→ The 18 products EarthLink Network builds in-house

For more about the company and each product, visit www.eln.ne.jp.