Coolify logoCoolify

Node.js Multi-Core Scaling

The Problem

JavaScript runtimes execute their event loop on a single thread per process. One node app.js (or bun/deno) process saturates one CPU core, regardless of host capacity — whether it's serving HTTP, processing queues, running cron jobs, or doing CPU-bound work.

This applies to every major JS runtime:

RuntimeEngineSingle-threaded event loop
Node.jsV8Yes
BunJavaScriptCoreYes
DenoV8Yes

It's a runtime characteristic, not a platform limit — the same constraint applies on bare metal, Docker, Kubernetes, or any PaaS.

Coolify Containers Have No CPU Cap

By default, Coolify does not limit container CPU, so all host cores are available — you only need to tell your runtime to use them.

The Fix

Run multiple worker processes inside the container. Each runtime has its preferred mechanism:

RuntimeApproachCode ChangeNotes
Node.jsPM2 cluster modeNoneEasiest; wraps your existing start command
Node.jsnode:cluster moduleApp-levelBuilt-in, no extra dependency
BunBun.serve({ reusePort: true })One-line app changeKernel load-balances via SO_REUSEPORT
DenoDeno.serve({ reusePort: true })One-line app changeSame kernel mechanism as Bun

This guide covers PM2 cluster mode (Node.js) and reusePort (Bun, Deno), with examples for the Dockerfile and Nixpacks build packs.

Technical Background

  • V8's (and JavaScriptCore's) event loop is single-threaded per process.
  • libuv's thread pool (4 threads by default in Node) offloads some I/O work, but your JavaScript still runs on a single core.
  • To use more than one core you need either multiple processes inside the container, or a runtime that supports multi-process listeners (Bun and Deno via reusePort).

Dockerfile Example (PM2 Cluster Mode)

Use this when your app is built with the Dockerfile build pack.

FROM node:lts-alpine

WORKDIR /app
COPY package*.json ./
RUN npm ci --omit=dev && npm install -g pm2

COPY . .

EXPOSE 3000
CMD ["pm2-runtime", "-i", "max", "dist/index.js"]

Key points:

  • pm2-runtime -i max forks one worker per available CPU core and keeps PM2 in the foreground (required because Docker's PID 1 must not exit).
  • Replace dist/index.js with your actual entry file.
  • Expose the port your app listens on (3000 here) in your platform's networking settings — in Coolify, this is the Ports Exposes field.

Nixpacks Example

Use this when your app is built with the Nixpacks build pack.

Option A — Set NIXPACKS_START_CMD as an Environment Variable

Set the following environment variable on your application (in Coolify: open your application → Environment Variables):

NIXPACKS_START_CMD=pm2-runtime -i max dist/index.js

Then add pm2 to the dependencies section of package.json so Nixpacks installs it during the build.

Option B — nixpacks.toml in the Repo Root

[phases.setup]
nixPkgs = ["nodejs", "pm2"]

[start]
cmd = "pm2-runtime -i max dist/index.js"

This keeps the multi-core configuration in version control and works across local and remote builds.

Bun Alternative (One-Line Multi-Core)

Bun's HTTP server can bind the same port across multiple processes using SO_REUSEPORT. Run N Bun processes and the kernel load-balances incoming connections between them.

App Code

Bun.serve({
  port: 3000,
  reusePort: true,
  fetch(req) {
    return new Response("Hello from Bun");
  },
});

Dockerfile

FROM oven/bun:alpine

WORKDIR /app
COPY package.json bun.lock ./
RUN bun install --production

COPY . .

EXPOSE 3000
# Spawn one Bun process per available core.
CMD ["sh", "-c", "for i in $(seq 1 $(nproc)); do bun run server.js & done; wait"]

Nixpacks (nixpacks.toml)

[phases.setup]
nixPkgs = ["bun"]

[start]
cmd = "sh -c 'for i in $(seq 1 $(nproc)); do bun run server.js & done; wait'"

Notes:

  • reusePort: true requires Linux kernel ≥ 3.9 (all modern distros).
  • Each Bun process is independent — there's no primary/worker IPC, so use Redis or a database for shared state.
  • Simpler than PM2 but has no built-in auto-restart per worker; pair with Docker's restart: unless-stopped (Coolify's default) for crash recovery of the parent shell.

Deno Alternative (One-Line Multi-Core)

Deno's Deno.serve accepts the same reusePort flag, so you can spawn N processes that all bind the same port and let the kernel distribute connections.

App Code

Deno.serve({ port: 3000, reusePort: true }, (_req) => {
  return new Response("Hello from Deno");
});

Dockerfile

FROM denoland/deno:alpine

WORKDIR /app
COPY . .
RUN deno cache server.ts

EXPOSE 3000
# Spawn one Deno process per available core.
CMD ["sh", "-c", "for i in $(seq 1 $(nproc)); do deno run --allow-net server.ts & done; wait"]

Nixpacks (nixpacks.toml)

[phases.setup]
nixPkgs = ["deno"]

[start]
cmd = "sh -c 'for i in $(seq 1 $(nproc)); do deno run --allow-net server.ts & done; wait'"

Same caveats as Bun apply: no IPC between processes, no built-in per-worker restart.

Caveats

In-Process State Does Not Scale

Workers do not share in-process memory. Sessions, in-memory caches, rate-limit counters, and WebSocket connection maps must move to Redis or another external store.

  • WebSockets: connections are split across workers. Use sticky sessions at the proxy layer, or move pub/sub to Redis so any worker can deliver messages.
  • Graceful shutdown: honor SIGTERM in each worker. PM2 does this automatically.
  • Memory: scales roughly linearly — N workers ≈ N × single-process RSS. Size the server accordingly.
  • Log output: PM2 merges logs across workers; with Bun's reusePort approach each process writes independently, which may interleave. Prefer structured JSON logs.

Verifying Multi-Core Use

After deploying, SSH to the host running the container and inspect the processes:

docker exec -it <container> top

You should see multiple node, bun, or deno processes. Hit the app under load (for example with autocannon -c 200 http://<host>:3000/) and confirm that htop/top shows load spread across all host cores, not pegged on one.

On this page