Getting a Node.js app to run on your laptop is easy. Getting it to run the same way on a teammate's machine, in CI and on a production server is where projects fall apart. A different Node version, a tool installed globally on one machine, or an unpinned dependency is enough to break a deploy.
If you come from Python, you already know the fix: a virtual environment, where each project carries its own interpreter and libraries. Node.js can give you the same guarantee, but only if you set it up deliberately from day one.
This post covers what to know when starting a Node.js project meant for production, and the steps I follow so that everything the project needs is declared inside the project.
Where production environments drift
Most "works on my machine" bugs in Node.js don't come from the code. They come from the environment around it.
That environment drifts in three places:
- The Node runtime itself. If you installed Node from an installer or system package manager, every project on your machine shares one version. That's like every Python project sharing the system Python.
- Global installs with
-g. Runningnpm install -g nodemonornpm install -g typescriptmakes a tool available everywhere. Your project then works on your laptop and breaks on everyone else's. - Unpinned versions. Without a committed lockfile, two people running
npm installon the same day can get different dependency trees.
Lock down those three and you have the Node equivalent of a venv.
FYI: a plain npm install <package> (without -g) already installs into the project's own node_modules/ folder, so libraries are project-local by default. The steps below take care of the runtime, the tools and the versions around them.
Python habits, translated to Node
If you think in Python terms, this mapping is the whole idea in one table.
| What you want | Python | Node.js |
|---|---|---|
| Pick the runtime version per project | pyenv |
nvm, fnm or Volta |
| Record which runtime version to use | .python-version |
.nvmrc + engines in package.json |
| Isolated library folder | python -m venv .venv |
node_modules/ (automatic) |
| Activate the environment | source .venv/bin/activate |
nvm use |
| Declare dependencies | requirements.txt / pyproject.toml |
package.json |
| Lock exact versions | poetry.lock / pinned requirements |
package-lock.json |
| Recreate the environment exactly | pip install -r requirements.txt |
npm ci |
| Run a tool from the environment | python -m pytest |
npx eslint or npm run lint |
The setup, step by step
1. Install Node through a version manager
Use nvm (macOS/Linux), nvm-windows, fnm or Volta. Don't install Node from an installer or system package manager. A version manager lets each project choose its own Node version, exactly like pyenv.
2. Create the project and pin its Node version
mkdir my-app && cd my-app
nvm install --lts
nvm use --lts
node -v > .nvmrc
The .nvmrc file records the exact version. Anyone who runs nvm use in this folder gets the same Node you used.
3. Initialize git and ignore what shouldn't be committed
git init
echo "node_modules/" >> .gitignore
echo ".env" >> .gitignore
node_modules/ is rebuilt from the lockfile, so it never goes into git. Same idea as never committing .venv/.
4. Create package.json and declare the Node version
npm init -y
npm pkg set engines.node=">=$(node -v | cut -c2-)"
The engines field tells npm, CI and other developers which Node version the project expects.
5. Add a project-level .npmrc to enforce the rules
Create a .npmrc file in the project root:
engine-strict=true
save-exact=true
engine-strict makes npm refuse to install on the wrong Node version. save-exact saves 4.21.2 instead of ^4.21.2, so upgrades happen only when you choose.
6. Install every dependency locally, never with -g
npm install express
npm install -D nodemon eslint typescript
Runtime libraries go in dependencies. Build and dev tools go in devDependencies with -D. That includes tools people usually install globally, like TypeScript, ESLint and nodemon.
7. Run tools through npx or npm scripts
Since nothing is global, call tools from the project:
npx eslint .
npx tsc --init
Better still, wire them into package.json:
"scripts": {
"dev": "nodemon src/index.js",
"lint": "eslint ."
}
npm scripts automatically put node_modules/.bin on the path. So npm run dev always uses the project's own nodemon, at the version in the lockfile.
8. Commit the lockfile and use npm ci
Commit package-lock.json. On a new machine or in CI, recreate the environment with:
nvm use
npm ci
npm ci deletes any existing node_modules/ and installs exactly what the lockfile says. It fails if package.json and the lockfile disagree, which is what you want in CI.
9. Check for global leakage
npm ls -g --depth=0
Ideally this lists only npm and possibly corepack. Anything else is a global tool some project might be silently depending on. Move it into the project as a dev dependency and uninstall it globally with npm uninstall -g <name>.
Optional: pin the package manager too
If your team uses pnpm or Yarn, the package manager's version can drift just like Node's. Corepack fixes that:
corepack enable
corepack use pnpm@latest
This writes a packageManager field into package.json, so everyone runs the same pnpm or Yarn version. Newer Node releases may no longer bundle Corepack. If corepack isn't found, install it once with npm install -g corepack. That is the one global install worth making, because it exists to keep everything else project-local.
The checklist
Before you write your first line of app code, the project should have:
- Node installed through a version manager, not system-wide
-
.nvmrcwith the exact Node version -
engines.nodeinpackage.json -
.npmrcwithengine-strict=trueandsave-exact=true -
node_modules/and.envin.gitignore - Every library and tool installed locally, dev tools with
-D - Tools run through
npxor npm scripts -
package-lock.jsoncommitted, andnpm ciused in CI -
npm ls -g --depth=0showing nothing your projects depend on
The rule behind all of it is simple: if a project needs it, the project should declare it. The runtime version, the libraries and the tools all live in files inside the repo. A new teammate should be able to clone, run nvm use && npm ci, and have exactly the environment you have. That's what a Python venv gives you, and it's the baseline every production Node.js project should start from.