How to Build a .gitignore That Actually Keeps node_modules and Secrets Out of Git
A practical guide to writing a .gitignore from language, framework, and OS templates, with glob patterns explained and a worked Node example you can copy.
How to Build a .gitignore That Actually Keeps node_modules and Secrets Out of Git
The first commit of a new repo tells you a lot about whoever set it up. If it contains a 40,000-file node_modules/ folder, a .env with a live database password, and a .DS_Store, the author skipped the one file that prevents all three: .gitignore. That file is plain text, it lives at the root of your repository, and every line is a pattern Git checks before it decides whether to track something.
This guide walks through how a .gitignore actually works, why ignoring build output and secrets matters more than it looks, and how the rules behave once you understand them as glob patterns rather than file lists. If you'd rather skip the typing, the .gitignore Generator stacks language, framework, and OS templates into one deduped, sectioned file in a couple of clicks.
What .gitignore actually does (and the one thing it can't)
A .gitignore is a list of patterns. When Git encounters an untracked file, it checks the file's path against every pattern, top to bottom, and the last matching rule decides whether the file is ignored. That's the whole mechanism.
The catch that trips up nearly everyone: .gitignore only governs files Git isn't already tracking. If you committed node_modules/ last week and add it to .gitignore today, Git keeps following all 40,000 files, because they're already in the index. The ignore rule is for new, untracked paths only. To untrack something already committed, you remove it from the index first:
git rm -r --cached node_modules/
git commit -m "stop tracking node_modules"
The --cached flag drops the files from Git's index but leaves them on disk. After that commit, the .gitignore rule finally takes over. If you'd rather keep the underlying Git commands handy, the Git cheatsheet lists the ones you'll reach for during cleanup.
Why ignoring build artifacts and dependencies matters
Three categories belong in almost every .gitignore, for three different reasons.
Dependencies like node_modules/, vendor/, or target/ are reproducible from a lockfile. Committing them bloats the repo by hundreds of megabytes, slows every clone, and produces merge conflicts that no human should ever read. You commit package.json and package-lock.json; the dependency tree rebuilds from those. If you want to confirm what your lockfile resolves to, the npm cheatsheet covers the install and audit commands.
Build output like dist/, build/, .next/, and *.log is generated, not authored. Tracking it means every build dirties your working tree and every pull risks a conflict on a file you'll regenerate anyway. Ignore the output directory and let CI rebuild it.
Secrets are the category where a mistake costs money. A committed .env with an API key, or a Terraform *.tfstate file holding a plaintext database password, lives in your Git history forever — deleting it in a later commit does not remove it from earlier ones. Anyone who clones the repo can read it. Ignoring secret files before the first commit is the cheapest security control you have. To double-check nothing already leaked into a tracked file, run it through the env secret scanner.
How the glob patterns work
Patterns in .gitignore are globs, not exact paths, and a few rules cover most of what you'll write:
node_modules/— the trailing slash means "directory." It ignores the folder and everything inside it.*.log— the asterisk matches any run of characters, so this catcheserror.log,debug.log, andnpm-debug.loganywhere in the tree./dist— a leading slash anchors the pattern to the repo root. Without it,distwould also match a nestedsrc/components/dist/. With it, only the top-leveldistmatches.**/temp— a double asterisk matches across directory levels, so this ignores anytempfolder at any depth.!.env.example— a leading!negates, re-including a file an earlier pattern ignored.
That last one is the workhorse for the secrets case. You ignore every environment file broadly, then rescue the one harmless template you do want committed:
.env*
!.env.example
Order matters because the last matching rule wins. The broad .env* catches .env, .env.local, and .env.production; the negation that follows pulls .env.example back in. Reverse those two lines and the negation does nothing, because .env* would match last. One sharp edge: you cannot re-include a file whose parent directory is ignored. dir/ followed by !dir/keep.txt fails, because Git never descends into an ignored directory — ignore the contents with dir/* instead, then negate the file. If your team keeps an .env.example as the canonical key list, a tool like the dotenv validator keeps the example honest against what the app actually reads.
A worked example: a minimal Node .gitignore
Here is a small, correct .gitignore for a Node project, covering dependencies, secrets, and build output:
### Node ###
node_modules/
npm-debug.log*
*.log
### dotenv ###
.env*
!.env.example
### Build ###
dist/
build/
.next/
### macOS ###
.DS_Store
Five sections, each labelled so the next person knows why a line is there. node_modules/ keeps the dependency tree out. The dotenv block ignores every env file but keeps the example. dist/, build/, and .next/ drop the compiled output. .DS_Store keeps macOS Finder cruft from leaking to teammates on Linux. Notice *.log appears once even though Node and several other templates declare it — when you stack templates, you want that rule deduplicated, not repeated in every section.
My rule of thumb after one too many leaks
I learned the secrets lesson the expensive way. Early on I pushed a side project to a public repo with a config.js that held a live Stripe key, caught it twenty minutes later, rotated the key, and rewrote history — but the key was already scraped. Since then my first action in any repo, before git init even finishes feeling new, is to write the .gitignore. I default to ignoring anything that smells like a credential (.env*, *.pem, *.key, *.tfstate) and only loosen it with explicit ! negations for the handful of template files I genuinely want shared. It costs ten seconds and it has saved me from at least three more bad mornings.
Keep per-machine junk out of every project
OS and editor files — .DS_Store, Thumbs.db, .idea/ — don't belong in any single project's .gitignore, because they're about your machine, not the project. Put them in a global ignore file once:
git config --global core.excludesFile ~/.gitignore_global
Paste the macOS, Windows, and editor sections into ~/.gitignore_global and every repo on your machine ignores them, while each project's checked-in file stays focused on its own build output. That split — per-project rules in the repo, per-machine rules in the global config — is the cleanest way to keep both files readable.
If you're configuring the surrounding infrastructure for a project too, the .htaccess generator and the robots.txt generator cover the other text files a new repo usually needs alongside its .gitignore.
A .gitignore is never finished — it grows as the repo does. Start from solid templates, dedupe the overlap, anchor what needs anchoring with a leading slash, and negate the exceptions. Get those four habits right and the file quietly does its job for the life of the project.
Made by Toolora · Updated 2026-06-13