Audit `package.json` scripts before your first buildpack deploy
Setup
I was scaffolding a new Node webapp onto an existing repo — one that had previously hosted a static site generator. The new webapp had its own Express server, its own template files under views/, and its own package.json with a start script pointing at server.js. The old toolchain (Eleventy) was still present in devDependencies and still had a "build": "eleventy" entry in scripts. It just wasn't the thing being deployed anymore.
First push to Heroku. Expected: the buildpack installs dependencies, finds web: node server.js in the Procfile, boots. Done.
Attempt
The push went through. The buildpack detected a Node.js app, resolved a runtime version, installed 201 packages without errors. Then it hit the Build phase and ran npm run build — automatically, without being asked.
-----> Build
Running build
> myapp@0.1.0 build
> eleventy
[11ty] Problem writing Eleventy templates:
[11ty] 1. Having trouble rendering njk template ./views/article.njk
[11ty] Error: template not found: _layout.njk
[11ty] Wrote 0 files in 0.14 seconds (v3.1.5)
[11ty] Eleventy Fatal Error (CLI)
Eleventy had found the new webapp's Nunjucks templates under views/ and tried to render them as a static site. Those templates extend _layout.njk, which lives in a completely different lookup path than Eleventy expected. Template not found. Build exited 1. Deploy dead.
The fix was one line in package.json: rename "build" to "build:legacy". Eleventy locally moves to npm run build:legacy; the buildpack sees no build script and skips the phase entirely. Second push succeeded.
Signal
One failed deploy round-trip. The failure was completely deterministic — the same push would fail every time until the script name changed. The fix took about thirty seconds once the cause was understood.
Why it worked
Heroku's Node buildpack has a documented but easy-to-forget behavior: if package.json contains a "build" script, the buildpack runs it automatically during compile, before the dyno ever starts. There is no opt-in required and no warning in the push output that this is about to happen. It just runs.
The trap here is repo history. A repo accumulates scripts as toolchains come and go. The old "build" script was doing real, correct work under the old toolchain — it just had no business running in the new deployment context. The new webapp didn't need a build step at all. But the name build is a reserved trigger, and leaving it in place meant the old toolchain would fire on every deploy as long as its dependencies were still installed.
The fix — rename rather than delete — is deliberate. It preserves local access to the old toolchain without advertising the script to any system that scans for build by convention. build:legacy is invisible to the buildpack, visible to a developer who knows to look for it.
The same audit applies more broadly. Before a first deploy to any buildpack-based platform, scan scripts for names that the platform treats as lifecycle hooks: build, heroku-postbuild, prepare (npm-specific). Any of those will auto-execute. Extend the same caution to CI workflows (GitHub Actions runs build jobs if they exist) and git hooks — any system that triggers on script names by convention, rather than by explicit invocation, is subject to this failure mode. A script's name is a contract with every automated system that reads your package.json, not just with the developer who originally wrote it.