#package.json for production services
package.json is the manifest of a Node project: what it's called, how Node should interpret its files, which commands it supports, what it depends on and which runtime it expects. It isn't code, but it decides what code gets installed and how it runs — so it's part of your production surface (and your supply chain).
The mental model is two files with two jobs. package.json states intent: "I need express, any 5.x from 5.2.1 up". package-lock.json records fact: "this install used express 5.2.1 from this URL with this hash, and here are the exact versions of every other package it pulled in". You edit the first; npm writes the second; you commit both.
{
"name": "orders-service",
"version": "1.4.2",
"private": true,
"type": "commonjs",
"main": "dist/server.js",
"scripts": {
"build": "tsc",
"build:watch": "tsc --watch",
"dev": "node --watch --env-file-if-exists=.env dist/server.js",
"start": "node dist/server.js",
"test": "jest",
"test:coverage": "jest --coverage"
},
"dependencies": {
"express": "^5.2.1",
"pino": "^10.4.0"
},
"devDependencies": {
"@types/node": "^24.0.0",
"jest": "^30.5.2",
"supertest": "^7.3.1",
"typescript": "^7.0.2"
},
"engines": {
"node": ">=24 <25"
},
"packageManager": "npm@11.19.0"
}name,version,private:versionfollows semver (MAJOR.MINOR.PATCH)."private": truemakesnpm publishrefuse — cheap insurance for an internal service whose name might be free on the public registry.type:"commonjs"(the default) or"module"decides how.jsfiles in this package are loaded;.cjsand.mjsalways mean CommonJS and ESM regardless (see CommonJS vs ES modules). It changes interpretation only — it doesn't convertrequirecode toimport.mainis the classic entry point. Libraries useexportsinstead, which both maps entry points (".": "./dist/index.js","./client": "./dist/client.js") and blocks deep imports of anything not listed (see Module resolution). A private service rarely needs either;startruns the file directly.scripts:npm startandnpm testare built-in names; anything else isnpm run build.startruns the compiled output, not a watcher.devuses Node's own--watch(restart on change) and--env-file-if-exists(load.envif present) — both built into Node 22+, no nodemon or dotenv needed.dependenciesvsdevDependencies: dependencies are needed at runtime (express, pino); devDependencies are needed to build or test (TypeScript, Jest, type definitions). Production installs skip the latter. A package your running coderequires must be independencies, or the production image crashes withCannot find module— tests pass locally because dev installs have everything.enginesdocuments the Node range. With npm it's advisory: I set"node": "<21"on a dependency andnpm installon Node 26 printednpm warn EBADENGINE Unsupported engineand installed anyway. With--engine-strict(orengine-strict=truein.npmrc) the same install failed withnpm error notsup Required: {"node":">=18 <21"}.packageManagernames the exact tool version; it's only enforced if you use Corepack.
Ranges say what a fresh resolution may pick. I checked them with the semver package npm itself uses:
// semver-demo.cjs — what each range accepts
const semver = require('semver');
const versions = ['1.2.3', '1.2.9', '1.3.0', '1.9.9', '2.0.0'];
for (const range of ['1.2.3', '~1.2.3', '^1.2.3']) {
console.log(range.padEnd(7), versions.filter((v) => semver.satisfies(v, range)).join(' '));
}
const zero = ['0.2.3', '0.2.9', '0.3.0'];
console.log('^0.2.3 ', zero.filter((v) => semver.satisfies(v, '^0.2.3')).join(' '));
console.log('^0.0.3 ', ['0.0.3', '0.0.4'].filter((v) => semver.satisfies(v, '^0.0.3')).join(' '));
console.log('prerelease in ^1.2.3?', semver.satisfies('1.3.0-beta.1', '^1.2.3'));
// 1.2.3 1.2.3
// ~1.2.3 1.2.3 1.2.9
// ^1.2.3 1.2.3 1.2.9 1.3.0 1.9.9
// ^0.2.3 0.2.3 0.2.9
// ^0.0.3 0.0.3
// prerelease in ^1.2.3? false- An exact version
1.2.3accepts only itself.~1.2.3allows patch updates:>=1.2.3 <1.3.0, so1.2.9yes,1.3.0no. ^1.2.3allows minor and patch:>=1.2.3 <2.0.0. That's npm's default when younpm install express, on the promise that minor versions don't break you.- The caret is stricter below 1.0:
^0.2.3means<0.3.0, and^0.0.3means exactly0.0.3. Semver treats0.xas unstable, so the leftmost non-zero part is the one that's locked. People are often surprised that^0.2.3won't pick up0.3.0. - Prereleases (
1.3.0-beta.1) are excluded from normal ranges unless the range itself names a prerelease. - Ranges explain why the lockfile matters: without it, two installs a week apart can resolve different versions of any of hundreds of transitive packages. The sandbox for this site lists 12 direct dependencies; its
package-lock.jsonhas 466 package entries, each with an exactversion, aresolvedtarball URL and anintegrityhash (sha512-…) that npm checks after download.
npm ci vs npm installnpm install resolves: it reads package.json, uses the lockfile as a guide, updates node_modules incrementally, and rewrites the lockfile if they disagree. npm ci reproduces: it installs exactly what the lockfile says, or fails. I ran both in a tiny project with local packages (--offline, so nothing was downloaded):
# 1. No lockfile yet
$ npm ci
npm error code EUSAGE
npm error The `npm ci` command can only install with an existing package-lock.json or
npm error npm-shrinkwrap.json with lockfileVersion >= 1. …
# 2. Someone adds a dependency to package.json but doesn't commit the updated lockfile
$ npm ci
npm error `npm ci` can only install packages when your package.json and package-lock.json
npm error or npm-shrinkwrap.json are in sync. Please update your lock file with `npm install`
npm error before continuing.
npm error Missing: shout@1.0.0 from lock file
$ echo $?
1
# 3. A stray file in node_modules
$ touch node_modules/.marker
$ npm install → up to date (.marker still there)
$ npm ci → added 2 packages (.marker gone: node_modules was deleted first)
# 4. Production installs
$ npm ci --omit=dev → added 1 package (only the dependency)
$ NODE_ENV=production npm ci → added 1 package (same: NODE_ENV=production implies --omit=dev)
$ npm ci --production → npm warn config production Use `--omit=dev` instead.npm cirequires a lockfile. That's deliberate: CI should never be the place where versions get chosen.- When
package.jsonand the lockfile disagree,npm installwould quietly fix the lockfile;npm cifails the build and names what's missing. That catches the "works on my machine because I didn't commit the lockfile" mistake before it ships. npm cideletesnode_modulesand installs from scratch, so leftovers from earlier installs (or a developer's manual hacking) can't leak into the build.npm installleaves what's there.--omit=devskips devDependencies. SettingNODE_ENV=productionhas the same effect on npm, which is a trap in Docker: set it in the build stage beforenpm ciand your build tools disappear (more in A production Dockerfile).--productionstill works but is deprecated.- Rule: developers run
npm install(and commit the lockfile changes); CI and Dockerfiles runnpm ci.