A teammate ran npm pack --dry-run on one of our published packages during standup and read the file list out loud. Forty-one files. Two of them were a local env file and a scripts/notes.md containing an internal hostname.
Both were in .gitignore. Both had been in .gitignore for a year.
Here is the part that took me an hour to accept: npm publish ignores .gitignore the moment a .npmignore file exists next to it. It does not merge the two. It does not warn. It picks one file and forgets the other exists.
TL;DR
- Three mechanisms decide what ends up in your tarball: the
filesarray inpackage.json,.npmignore, and.gitignore. Only the first one found applies. - Adding a two-line
.npmignoreto drop your test folder silently switches off every rule your.gitignorewas enforcing. - npm has a small fixed always-excluded list (
.git,node_modules,.npmrc,package-lock.json, and friends). Env files and key files are not on it. -
npm pack --dry-runprints the exact file list before anything leaves your machine. So doesnpm publish --dry-run. - Use the
fileswhitelist inpackage.jsoninstead of.npmignore, and assert the tarball contents in CI.
Why does npm publish ignore .gitignore?
Because .gitignore is only a fallback. When npm builds the tarball it looks for an ignore source in this order, and stops at the first hit:
- The
filesarray inpackage.json(a whitelist) .npmignore.gitignore
The documented sentence is "if there is no .npmignore file, npm will use .gitignore." Everyone reads that as "npm also uses .gitignore." The word doing all the work is no.
So the failure mode is not a typo. It is a perfectly reasonable action: you notice your tarball is 4 MB because it ships fixtures, you add .npmignore with test/ in it, the tarball drops to 300 KB, you ship. What you actually did was revoke every exclusion in .gitignore in one commit.
It gets more granular than people expect: the swap happens per directory. An .npmignore sitting in src/ overrides the .gitignore in src/, while a directory without one still falls back to its own .gitignore. A single stray ignore file deep in the tree can change what ships out of that subtree only.
How do I reproduce the .npmignore override rule?
Six commands in an empty directory. No git repo required, since npm reads the ignore files by name rather than asking git.
mkdir leak-demo && cd leak-demo
npm init -y > /dev/null
printf 'TOKEN=hunter2\n' > .env.local
printf '.env.local\ncoverage/\n' > .gitignore
mkdir test && echo '// 2MB of fixtures' > test/fixture.js
npm pack --dry-run
Output lists package.json and test/fixture.js. No env file. Your .gitignore is quietly doing its job.
Now add the innocent-looking file everybody adds:
printf 'test/\n' > .npmignore
npm pack --dry-run
test/fixture.js is gone, exactly as intended. And .env.local is now in the list, shipping your token to the registry, because .gitignore stopped being consulted the instant .npmignore appeared.
Same directory. Same .gitignore. One new file, opposite behaviour.
What does npm always exclude no matter what?
A short, fixed list that you cannot turn off, and that is much smaller than most people assume. It includes .git, CVS, .svn, .hg, .DS_Store, ._*, .*.swp, npm-debug.log, .npmrc, node_modules, config.gypi, *.orig, and package-lock.json.
Read that list again and notice what is missing: env files of every flavour, *.pem, *.key, terraform.tfvars, your notes.md, your .vscode/launch.json with a database URL in it. None of those are special to npm. They are ordinary files that only your ignore rules were protecting.
There is also a short always-included list that beats your ignore rules in the other direction: package.json, README, LICENSE / LICENCE, and whatever file your main field points at.
How do I see exactly what npm will publish?
Run the pack before you publish and read the list. Both of these are safe and upload nothing:
npm pack --dry-run # prints the file list npm would include
npm publish --dry-run # same list, plus the publish-time checks
If you want ground truth instead of a printed summary, build the real tarball and open it:
tar -tf "$(npm pack --silent)"
Every path inside is prefixed with package/, which is just how npm tarballs are laid out. npm pack also runs your prepack and prepare scripts, so anything those generate shows up in the list too. That is the exact set of bytes a consumer gets.
One more habit worth two seconds: npm publish prints a summary with the total file count and unpacked size. If that number jumps from 12 files to 41 between releases, something changed in your ignore rules.
What should I use instead of .npmignore?
Use the files array in package.json. It is a whitelist, it sits in the file you already review during releases, and it wins over both ignore files:
{
"name": "my-pkg",
"version": "1.4.0",
"main": "dist/index.js",
"files": ["dist", "bin"]
}
Now the default is nothing ships, and each new thing you ship is a visible diff in a file your reviewers already read. Compare that to .npmignore, where the default is everything ships and a leak looks like the absence of a line.
Then make it impossible to regress. This script fails if a blocked pattern makes it into the real tarball:
#!/usr/bin/env bash
# scripts/check-tarball.sh
set -euo pipefail
tarball="$(npm pack --silent)"
files="$(tar -tf "$tarball")"
rm -f "$tarball"
blocked='(/\.env($|\.)|\.pem$|\.key$|^package/(test|tests|__tests__)/)'
if printf '%s\n' "$files" | grep -Eq "$blocked"; then
echo "Blocked files found in tarball:" >&2
printf '%s\n' "$files" | grep -E "$blocked" >&2
exit 1
fi
printf '%s\n' "$files" | wc -l | xargs echo "tarball file count:"
Wire it in as a release gate:
{
"scripts": {
"prepublishOnly": "bash scripts/check-tarball.sh"
}
}
prepublishOnly runs only on npm publish, and calling npm pack inside it triggers prepack, not prepublishOnly, so it will not recurse. Run the same script in CI on pull requests too, since that is where a new .npmignore shows up in a diff nobody connects to the package contents.
If you prefer machine-readable output, npm pack --dry-run --json gives you a structured manifest to assert on. Check the shape once on your npm version before you depend on it in CI; the tar -tf version above works everywhere.
What if you already published a secret?
Assume it is public. Registries and mirrors copy tarballs fast, and a published version can be fetched by anything that saw it. npm does allow unpublishing under narrow conditions within 72 hours, but treat that as cleanup, not containment. Rotate the credential first, publish a clean version second, worry about the old tarball third.
The short answer
npm publish ignores .gitignore whenever a .npmignore file is present, because npm checks files in package.json, then .npmignore, then .gitignore, and stops at the first one it finds rather than combining them. Adding an .npmignore to trim your tarball therefore disables every rule in .gitignore at the same time, and npm's own always-excluded list does not cover env files, key files, or private notes. Check what actually ships with npm pack --dry-run, switch to the files whitelist so the default is to ship nothing, and assert the tarball contents in CI so the next person who adds one small ignore file cannot leak anything.
Written by the developer behind Preterview, an interview prep platform.
Top comments (0)