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. Theimportcommand and the Upload tab from part 4 do something else: they parseKEY=VALUElines and register keys one by one. - Destination and pointer are registered with the file.
--as .kube/configsets where the file lands on the receiving side;--pointer KUBECONFIGnames the environment variablerunsets to the temporary path..npmrcgotNPM_CONFIG_USERCONFIGwithout any option. - The receiving side uses
dotvault pullordotvault run.pullwrites pushed files to their destination paths in the same run that writes.env.runwrites them to a temporary directory, sets the pointer variables, and removes everything when the command exits.pull --fileselects 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-committurns that into a blocked commit. -
scanis not a guard for.envitself. A plainPASSWORD=valueline is not matched. Keep.envout of git with.gitignore; usescanfor secrets that leak into code, CI configuration and Dockerfiles.
Before you read on
- Where we start. Part 4 left
demo-appwith.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 onlyproject-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…andAKIAIOSFODNN7EXAMPLEdo not work anywhere. - Vocabulary. A "key" is a name and a value, such as
DATABASE_URL=…. A "file" is something registered whole withpush. A "pointer" is the environment variablerunsets 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.
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".
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.
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.
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.
4. Pulling on another machine
The receiving side: an empty directory with only project-config.json, and dotvault pull.
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.
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.
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.
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.
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.
| 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.
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.







![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.](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fpwwkuvw8ogdbkr8uxfzu.png)
![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(+)".](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fyu13iujc3wxxqbw0mgrj.png)
![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].](https://media2.dev.to/dynamic/image/width=800%2Cheight=%2Cfit=scale-down%2Cgravity=auto%2Cformat=auto/https%3A%2F%2Fdev-to-uploads.s3.us-east-2.amazonaws.com%2Fuploads%2Farticles%2Fm9cwhpfxq027s3na65ha.png)




