Deploying a Node.js App — the Five Signals That Decide Your Build
You can deploy a Node.js app without writing a Dockerfile — upload the project and a build agent works out the runtime, install command, and start command from your source. But "zero-config" doesn't mean "unpredictable": five things in your repository decide the outcome, and once you know which five, you can make the result deterministic. They are your lockfile, your engines field, your start script, your build script, and whether you listen on a port. Get those right and the build does exactly what you'd have written by hand.
1. Your lockfile picks the install command
This is the highest-leverage file in the repository, and it maps directly:
| What's in your repo | What gets run |
|---|---|
package-lock.json or npm-shrinkwrap.json |
npm ci |
pnpm-lock.yaml |
pnpm install --frozen-lockfile |
| No lockfile | npm install |
npm ci is the one you want: it installs exactly what the lockfile pins, so the build is reproducible and typically faster. It's also only used when a lockfile is actually present — no lockfile means a resolving npm install, which can pull different versions than you tested with.
So commit your lockfile. It's the single change that makes a zero-config build behave like a pinned one — and the zip layout guide covers what else to include.
One nuance: --omit=dev is only used for straightforward production servers with no build step. If you have a TypeScript build, devDependencies are needed to run it, so a full install happens — which is correct, and worth knowing before you wonder why your devDependencies were installed.
2. engines picks your Node version
The default is Node 20 LTS, which is the right answer for most projects. Node 22 is used when your package.json explicitly asks for it:
{
"engines": { "node": ">=22" }
}
If you need a specific major, say so there rather than hoping. There are also floors applied from what your dependencies require — discord.js v14 and above needs at least Node 18, and anything using undici, the global fetch API, or ReadableStream won't be given an old runtime that lacks them.
3. Your start script picks the run command
The build prefers a direct invocation — node server.js, node app.js, node index.js — and uses npm start only when your package.json start script is clearly the right thing to run.
That means ambiguity costs you. If your repo has an index.js that's an old example and a src/server.js that's the real entry point, be explicit:
{
"scripts": { "start": "node src/server.js" }
}
Delete entry points you don't use. A single obvious way to start the app is the whole requirement.
4. A build script gets run — if it exists
For TypeScript, npm run build is appended after a full install, so the compile happens before your app starts. That works as long as the script exists and succeeds in a clean checkout.
Two things to check before uploading:
npm ci && npm run buildpasses in a fresh clone. If it only works on your machine because of something in your global environment, it won't work in the build.- Your start script points at the compiled output, not the source:
node dist/server.js, notts-node src/server.ts. Running TypeScript through a loader in production is a slower start and an extra dependency for no benefit.
5. Listening on a port decides web vs worker
If your app listens on a port, it's a web service and gets that port mapped. If it doesn't, it's a worker — no port, nothing exposed, nothing to health-check.
That's the entire distinction, and it's why a Discord or Telegram bot needs no special handling: it's just a Node process that never listens. There's no fake HTTP server needed to keep it alive.
For web apps, bind 0.0.0.0 and read the port from the environment:
const port = process.env.PORT || 8080;
app.listen(port, '0.0.0.0');
Binding localhost instead is the classic container failure — it produces a successful deploy that returns 502, because nothing is listening where the router looks. More on that.
NODE_ENV=production is set for you, so you don't need to.
Don't add PM2
Worth saying explicitly, because it's the reflex: don't wrap your app in PM2, forever, or a clustering supervisor.
The platform already restarts your process when it exits. Adding your own supervisor puts a layer between the crash and the log, which hides the exit code — and the exit code is the most useful diagnostic you have when a bot or API keeps restarting. Two supervisors also disagree about who's in charge during a redeploy.
Same for clustering on a small quota: each worker is a full Node process with its own heap, so cluster multiplies memory rather than sharing it.
When to write a Dockerfile instead
The zip route is for getting from source to running quickly. Write a Dockerfile when you want control — a specific base image, native modules that need system packages, or a multi-stage build you've already tuned. Then push by git and the platform builds it.
Both paths end up in the same place; the difference is who decides the details. If you find yourself fighting the inferred build, that's the signal to take it over rather than tune the signals further.
FAQ
Do I need a Dockerfile to deploy Node.js?
No. Upload the project and the build agent infers the runtime, install command, and start command. A Dockerfile is for when you want explicit control.
Which Node version will my app get?
Node 20 LTS by default. Set engines.node in package.json if you need a different major — Node 22 is used when you ask for it explicitly.
Should I commit my lockfile?
Yes. A package-lock.json gets you npm ci and a reproducible install; without one you get a resolving npm install that may pick different versions.
How does my TypeScript get compiled?
npm run build is run after a full install. Make sure that script works from a clean checkout and that your start script points at the compiled output.
Why is my Node app returning 502?
It's almost certainly listening on localhost rather than 0.0.0.0, or on a different port than the one exposed. There's no health check, so the deploy still succeeds.
Should I use PM2 or cluster mode?
No. The platform restarts your process already, and PM2 hides the exit code you need for debugging. Clustering multiplies memory on a small quota.
Wrapping up
Inferred builds get a reputation for being magic, which makes them feel risky. They aren't magic — they're a small set of rules reading a small set of files. Commit the lockfile, declare your Node version, make one start path obvious, keep the build script honest, and listen on 0.0.0.0 if you listen at all. That's a deploy you can predict without writing a single line of Docker.