Bundle and protect a Node.js application into a single self-contained executable, one per CPU architecture (amd64 / arm64), with all dependencies included and the JavaScript compiled to V8 bytecode so your source is not shipped in readable form.
Built for the case where you install your apps on a client's servers (via Docker) and don't want them reading or trivially reverse-engineering your code.
your app ──▶ run its own build (tsc / webpack / nest / …)
──▶ esbuild: bundle entry + ALL deps into ONE .cjs file
──▶ obfuscate (string-array encryption + identifier mangling)
──▶ Node SEA + V8 snapshot: compile to bytecode + embed Node runtime
──▶ one executable per architecture (no Node needed on the client)
This tool raises the cost of reverse-engineering a lot. It does not make it impossible. Be honest with yourself about the threat model:
- Bytecode removes your readable JS. To inspect logic an attacker must decompile V8 bytecode — hard, tool-assisted, lossy, but possible.
- Obfuscation encrypts string literals and mangles names. But the program must decode its own strings at runtime, so a determined attacker running the binary can recover strings. Treat obfuscation as a speed bump on top of bytecode, not a vault.
- There is no DRM in client-side code. Anything that must run on the client's machine can ultimately be observed on the client's machine.
Practical guidance: for anything that truly must stay secret (signing keys, the "secret sauce" algorithm, license validation that can't be patched out), keep it server-side behind an API. Use this tool to protect the bulk of your application code and to make casual copying / inspection impractical — which is what it does very well.
| Need | Why |
|---|---|
| Node.js ≥ 18 | to run the tool |
| Docker (running) | to build for an OS/arch different from your machine — i.e. building Linux binaries on macOS/Windows, and to build any app with native addons. See Build modes. |
| Your project's package manager (npm/pnpm/yarn) | the tool runs your real build |
Building Linux binaries from macOS/Windows requires Docker because the packer embeds this machine's Node binary and generates the V8 snapshot with this runtime — both are host-specific and can't be produced for a different OS/arch on the host. Docker mode builds each architecture natively inside a Linux container.
npm install -g @navid.kianfar/node-bundle # global `node-bundle` command
# or run without installing:
npx -y @navid.kianfar/node-bundle --helpFrom source
pnpm install
pnpm build # produces dist/cli.cjs
# optional: npm link (to get a global `node-bundle` command)# Analyse a project without building anything:
node-bundle /path/to/your/app --analyze
# Build protected Linux binaries for amd64 + arm64 (auto-selects Docker on macOS):
node-bundle /path/to/your/app --targets amd64,arm64 --node 22 --obfuscate safeOutputs land in <project>/node-bundle-out/:
node-bundle-out/
yourapp-linux-x64 # amd64
yourapp-linux-arm64
Run one (no Node required on the host):
./node-bundle-out/yourapp-linux-x64 --your --app --args| Mode | When | What it does |
|---|---|---|
host |
building for the same OS+arch you're on, pure-JS apps | runs the whole pipeline locally — fastest |
docker |
building cross-OS/arch (e.g. Linux from a Mac) or apps with native addons | builds each target inside a linux/<arch> container via QEMU, so native addons and bytecode are generated natively and correctly |
auto (default)
|
— | picks docker when bytecode-cross-target or native addons make host unreliable, otherwise host
|
Native .node addons are architecture-specific machine code and cannot be
cross-compiled by copying — auto will route those builds through Docker.
If your app is a package inside a pnpm workspace (its deps use workspace:*), a plain
single-folder build can't resolve those internal packages or the shared base tsconfig.
Use --monorepo — point it at the package directory and the tool will, inside each
per-arch Linux container:
- find the workspace root (
pnpm-workspace.yaml), -
pnpm install→pnpm --filter <pkg>... build→pnpm --filter <pkg> deploy --prod(producing a self-contained package), then - bundle + obfuscate + bytecode-pack that deployed package.
node-bundle path/to/providers/whatsapp --monorepo \
--workspace-include providers \
--targets amd64,arm64 --out ./out --name whatsapp-
--workspace-include providerscopies only that top-level subtree into the build context (much faster than copying a monorepo that also contains a big frontend). Omit it to copy the whole workspace. - Requires pnpm + Docker (the recipe runs in containers so native addons and bytecode are produced for each target arch).
-
Native modules must be in the deployed closure. Optional peers (e.g.
better-sqlite3behind drizzle) and dynamically-resolved natives (e.g.sharp) may be dropped bypnpm deploy --prod; ensure they're real dependencies (or add them) so they get embedded.
node-bundle [projectDir] [options]
-o, --out <dir> output dir (default: <project>/node-bundle-out)
-n, --name <name> output base name (default: package.json "name")
--ext <ext> cosmetic extension, e.g. ".node"
--node <version> Node major version to embed (default: 22)
-t, --targets <list> e.g. "linux-x64,linux-arm64" or "amd64,arm64"
-m, --mode <mode> auto | host | docker (default: auto)
--obfuscate <level> off | safe | aggressive (default: safe)
--no-bytecode disable V8 bytecode (debug only; weakens protection)
--no-build skip your project's own build step
--build-command <c> override the detected build command
--entry <file> override the (post-build) entry file
--assets <globs> comma list of extra files to embed in the binary
--external <pkgs> comma list of extra packages to keep out of the bundle
--fresh-install copy project to temp + reinstall before building
--keep-temp keep the .node-bundle/ working dir
--esbuild-target <t> esbuild target (default: node<version>)
--analyze detect & print a report, then exit
--monorepo build a pnpm-workspace package (install+build+deploy, then bundle)
--workspace-include <list> monorepo: only copy these subtrees, nested ok (faster)
--static <dirs> comma "from[:to]" dirs shipped NEXT TO the binary (sidecar)
--config <path> external JSON config file (outside the project)
amd64, x86_64 → x64 · aarch64 → arm64 · bare arch tokens default to linux.
Configuration can live in three places (highest precedence first): CLI flags →
an external file passed with --config <path> → a node-bundle key inside
package.json → a node-bundle.config.json file in the project root. See
templates/node-bundle.config.example.json.
A staticDir is delivered one of two ways:
embed |
Where it lands | Served from | Use when |
|---|---|---|---|
false (sidecar)
|
a real folder next to the binary in the output dir | the process working directory (<cwd>/<to>) |
the app already reads process.cwd()/public (most web apps) |
true (embed)
|
inside the binary (extracted to a temp dir at first run) |
JSON.parse(process.env.NODE_BUNDLE_STATIC)[to] at runtime |
you want one truly self-contained file |
Embedded dirs set NODE_BUNDLE_STATIC to a JSON map of { to → absolute snapshot path }
so app code can locate them without hard-coding any extraction path.
In --monorepo mode, buildPackages builds another workspace package (e.g. a
co-located frontend) and gathers its output as a static dir — replacing the usual
COPY --from=frontend .../dist ./public Docker step:
{
"node-bundle": {
"buildPackages": [
{ "package": "@acme/frontend", "from": "dist", "to": "public", "embed": false }
]
}
}node-bundle builds @acme/frontend in the same pass as your app (inside each per-arch
container), then places its dist/ as public/ next to the binary. Make sure the
package's subtree is copied into the build context (--workspace-include).
| Level | Identifier mangling | String encryption | Control-flow flattening | Notes |
|---|---|---|---|---|
off |
– | – | – | bytecode only |
safe (default)
|
yes | base64 string array (all strings) | no | minimal runtime cost; good default |
aggressive |
yes | rc4 + string splitting | yes + dead-code injection | strongest; slower build & some runtime cost |
Module specifiers (require('x')) and Node built-ins are always left intact so external
native packages still resolve at runtime. selfDefending/debugProtection are
intentionally disabled — they break once the output is compiled into the snapshot.
The binaries are glibc-based — run them on a glibc image (distroless/cc-debian12,
debian:bookworm-slim, ubuntu). Do not use Alpine/musl. See
templates/Dockerfile.distroless for a minimal
multi-arch runtime image.
FROM gcr.io/distroless/cc-debian12
COPY node-bundle-out/myapp-linux-x64 /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]If you protect your app as part of docker build (rather than running node-bundle on
your machine and COPYing the result in), use --mode host and target only the stage's
own architecture — each docker buildx platform stage already is the target arch, and
Docker-in-Docker is not available during a build:
# syntax=docker/dockerfile:1
FROM node:22-bookworm AS protect
# native-addon toolchain (only needed if your app has native deps)
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npm install -g @navid.kianfar/node-bundle
WORKDIR /app
COPY . .
ARG TARGETARCH
# amd64/arm64 are accepted aliases; bare arch tokens default to linux
RUN node-bundle . --mode host --targets ${TARGETARCH} --node 22 --obfuscate safe \
--out /out --name myapp \
&& mv /out/myapp-linux-* /out/myapp
FROM gcr.io/distroless/cc-debian12
COPY --from=protect /out/myapp /usr/local/bin/myapp
ENTRYPOINT ["/usr/local/bin/myapp"]Build both architectures natively in one go:
docker buildx build --platform linux/amd64,linux/arm64 -t registry/myapp:1.0 --push .Detected automatically (by scanning node_modules for .node files / binding.gyp),
kept out of the JS bundle, staged with their runtime closure, and embedded per-arch. Because they're machine code, such apps
must build in docker mode (one container per arch). Some packages that resolve their
native binary dynamically (notably sharp, prisma's query engine) may need extra
help — add their platform packages or engine files via --assets, and test the binary.
-
ESM with top-level
awaitcan't be emitted as CommonJS; such entries aren't supported by the default pipeline. Most server apps (tsc→CJS, webpack, NestJS) are fine. -
Dynamic
require(variable)can't be traced into the bundle. The tool surfaces esbuild warnings; add the module via--assets/--externalif needed. - Alpine/musl runtime images won't run these glibc binaries.
- Binaries are large (~60–75 MB) because they embed the full Node runtime. That's expected for self-contained executables.
| Symptom | Fix |
|---|---|
Cannot build linux-x64 on a arm64 host |
Cross-arch/OS needs Docker — use --mode docker (default auto does this). |
Could not locate a compiled entry file |
Pass --entry <post-build .js>, or ensure your build produces e.g. dist/main.js. |
Snapshot … could not be built … falling back to source mode |
The app couldn't be snapshotted; the binary still works with obfuscation-only protection. Usually caused by code that runs at import time in a way V8 can't snapshot. |
Binary exits with loader/not found error |
You're on Alpine/musl — switch to a glibc base image. |
Docker mode: docker version failed
|
Start Docker Desktop / the daemon. |
-
detect — reads
package.json: package manager, TS vs JS, build command, native addons, entry candidates. - build — runs your project's real build (so framework transforms like NestJS decorator metadata are preserved — esbuild alone would drop them).
-
bundle — esbuild collapses the built entry + all pure-JS deps into one
.cjs; native packages stay external. - obfuscate — javascript-obfuscator encrypts strings + mangles names (module specifiers reserved).
-
pack — a self-owned packer (no
pkg/postject): the obfuscated bundle is compiled into a V8 startup snapshot and embedded, alongside the native-addon closure and runtime data, into a copy of this Node binary via Node's SEA mechanism (blob injected as an ELF note, SEA fuse flipped). Native/shared-lib packages self-extract to a temp dir on first run. Done once per target arch. - In docker mode, steps 1–5 run inside a
linux/<arch>container so everything is native to the target.
MIT