Docs menu
Node.js on Primcloud
How Primcloud detects a Node.js app, installs the runtime and packages, runs build script hooks, prunes `node_modules`, and picks the default web process.
On this page
Primcloud detects Node.js apps from the files in your repo, installs the Node.js runtime matching your engines.node requirement, chooses a package manager, installs dependencies from your lockfile, runs your build scripts, prunes node_modules, and, if you don't ship a Procfile, picks a sensible default web process. This page covers each of those phases and the config knobs that shape them.
Detection#
Primcloud runs the Node.js toolchain when the root of your repo contains a package.json. If a package.json is missing, the build fails with a "no language detected" error before starting.
server.js and index.js are secondary indicators. Primcloud uses them to synthesize a default web process when your package.json doesn't declare a start script (see Release phase conventions).
One language per app. Primcloud checks languages in a fixed order and builds with the first that matches, so a repo carrying markers for two languages resolves to one. Python is checked before Node.js: a repo with both a requirements.txt and a package.json builds as a Python app, and the package.json is ignored. If you mean to ship a Node.js app, don't leave another language's manifest at the root.
Node.js is the last language checked. A package.json beside a Bun lockfile (bun.lock or bun.lockb), a bunfig.toml, a .bun-version file, or a packageManager field naming bun@… builds as a Bun app. A package.json beside a Cargo.toml builds as a Rust app, with the JavaScript side built as its frontend.
Install phase#
The install phase runs in two parts: install the Node.js runtime, then install the package manager and dependencies.
Node.js runtime. The version is resolved from engines.node in package.json. If the field is missing, Primcloud falls back to the recommended LTS. Supported majors are documented in Runtime versions.
Package manager. Primcloud picks a package manager by walking this priority list:
- Vendored Yarn: a
yarnPathentry in.yarnrc.ymlpointing at a Yarn binary committed to the repo. packageManagerfield inpackage.json(e.g."packageManager": "[email protected]").engines.pnpminpackage.json.engines.yarninpackage.json.engines.npminpackage.json.- Presence of
yarn.lock: Yarn is used with a default version range. - Fallback: the npm bundled with the Node.js version above.
Whichever manager wins, its binary is installed into a cached build layer and put on PATH.
Dependencies. The install command is picked to match the manager, and it always runs against a lockfile:
| Package manager | Command | Required lockfile |
|---|---|---|
| npm | npm ci |
package-lock.json |
| pnpm | pnpm install --frozen-lockfile |
pnpm-lock.yaml |
| Yarn (classic and modern) | yarn install --frozen-lockfile / yarn install --immutable |
yarn.lock |
If none of the three lockfiles exist, the build installs the runtime and the package manager but skips dependency install and the build-script phase. You'll get a Node.js runtime but no node_modules. Ship a lockfile.
Compile phase#
Node.js apps don't have a separate "compile" step in the .NET/Java sense. The build phase runs your package.json scripts in a fixed order:
heroku-prebuild: always runs first if defined.heroku-buildif defined, otherwisebuild. Only one of the two runs (heroku-buildwins if both are present).heroku-postbuild: always runs last if defined.
Missing scripts are skipped silently. This is the primary hook for transpilation, bundling, framework builds (Next.js next build, Nuxt nuxi build, Vite bundling), asset generation, and anything else that needs to run once per deploy.
Skipping build scripts. Set enabled = false in project.toml under [com.heroku.buildpacks.nodejs] to disable the build-script phase entirely:
[_]
schema-version = "0.2"
[com.heroku.buildpacks.nodejs]
enabled = false
Cache behavior#
The Node.js build writes cached layers that Primcloud persists in a per-app cache image between builds:
- Node.js runtime layer: cached by exact version. A repeat deploy pinned to the same version reuses the installed runtime without re-downloading.
- Package manager binary layer: cached by exact version of npm/pnpm/Yarn.
node_modulesand package-manager cache: installed against your lockfile. Reused when the lockfile is unchanged.
You'll see either Reusing Node.js <version> or Installing Node.js <version> in the build log's Installing Node.js distribution section, and analogous lines for the package manager.
Dev-dependency pruning. After the build scripts finish, the build prunes node_modules. With pnpm and Yarn, devDependencies are removed, so devtools like TypeScript, bundlers, and linters aren't available at runtime. With npm, the build runs npm prune: packages your lockfile doesn't list are removed and devDependencies stay installed, so they ship in the runtime image.
Skip this step by setting the following in project.toml:
[_]
schema-version = "0.2"
[com.heroku.buildpacks.nodejs]
actions.prune_dev_dependencies = false
With pnpm or Yarn, do this only when a runtime dependency is misclassified as a devDependency and you can't move it. The correct fix is almost always to move it into dependencies. With npm, devDependencies stay installed either way, so skip pruning only when your app needs a package at runtime that a build script installs outside the lockfile.
Release phase conventions#
Procfile takes precedence. If your repo has a Procfile at the root, Primcloud uses it verbatim to define process types, the same as every other language on Primcloud. See Pod Formations for the file format.
Auto-detected web process kicks in when there's no Procfile, in this order:
- If
package.jsondeclares astartscript:web: npm start(orpnpm start/yarn start, matching the installed manager). - Otherwise, if
server.jsexists at the root:web: node server.js. - Otherwise, if
index.jsexists at the root:web: node index.js. - Otherwise, no
webprocess is registered. The build produces nothing to run, so the deploy fails with a process-types error. Add aProcfile, astartscript, or a rootserver.js/index.js.
Port binding. Primcloud sets PORT on the web formation (the constant 5000), and your app must bind to it. Every Node.js HTTP framework reads process.env.PORT for you, either automatically or with a one-line change. Any other port is unreachable from outside the pod. Non-web formations get no PORT; a worker that doesn't serve HTTP doesn't need one.
Runtime environment. Primcloud sets these variables on your runtime process:
| Variable | Value | Purpose |
|---|---|---|
PATH |
With Node.js and package manager binaries prepended | So node, npm, pnpm, and yarn resolve. |
WEB_CONCURRENCY |
Derived from the pod's memory limit | Hint for cluster/PM2-style process managers. |
WEB_MEMORY |
Derived from the pod's memory limit | Hint for cluster/PM2-style process managers. |
PORT |
Assigned by Primcloud | Set on web process types only. |
WEB_CONCURRENCY and WEB_MEMORY are only hints. Primcloud does not launch a cluster manager on your behalf. Read them from your app if you want to size a worker pool.
Anything you set in the Secrets panel is layered on top of these at runtime.