juststart_
All posts

Starting a Production-Grade Node.js Project: What to Know Before You Write Code

, 5 min read

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:

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:

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.