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:

  1. Vendored Yarn: a yarnPath entry in .yarnrc.yml pointing at a Yarn binary committed to the repo.
  2. packageManager field in package.json (e.g. "packageManager": "[email protected]").
  3. engines.pnpm in package.json.
  4. engines.yarn in package.json.
  5. engines.npm in package.json.
  6. Presence of yarn.lock: Yarn is used with a default version range.
  7. 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:

  1. heroku-prebuild: always runs first if defined.
  2. heroku-build if defined, otherwise build. Only one of the two runs (heroku-build wins if both are present).
  3. 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:

project.toml
[_]
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_modules and 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:

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:

  1. If package.json declares a start script: web: npm start (or pnpm start / yarn start, matching the installed manager).
  2. Otherwise, if server.js exists at the root: web: node server.js.
  3. Otherwise, if index.js exists at the root: web: node index.js.
  4. Otherwise, no web process is registered. The build produces nothing to run, so the deploy fails with a process-types error. Add a Procfile, a start script, or a root server.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.