Single Executable Applications

This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production.

The reference material consulted while preparing these pages is listed in the section bibliography.

This page is written against Node.js 26 (26.10.0, released 2026-09-21, the Current line that becomes LTS on 2026-10-28), with the differences of Node.js 24 "Krypton" (24.21.0, Active LTS) and Node.js 22 "Jod" (22.23.3, Maintenance) called out; versions and stability levels were checked against the official documentation on 2026-10-04, and the examples were run on Node.js 26 (macOS, arm64) and, where the text says so, on 24 and 22. A single executable application (SEA) is a copy of the node binary with your program, and optionally its data files, injected into it, so that you can hand one file to a machine that has no Node.js installed. The whole feature has Stability 1.1 (Active development): it works and is tested on CI, but its configuration fields, its error messages and the way the blob is injected may still change between releases, so pin the Node.js version you build with and re-test on every upgrade. In the Bookshelf scenario that grows across this section (see Introduction & Architecture) the natural candidate is the command line tool that lists and searches books, delivered to colleagues as a single download. A SEA is not a compiler and not an installer: it runs your JavaScript on a full V8 and libuv runtime, so the file is as large as the node binary (about 145 MB for the builds shown here) plus your code and assets.

The stability and version map

Table 1. APIs covered on this page
API Stability (Node.js 26 docs) Version notes

Single executable applications (the feature as a whole)

1.1 Active development

Introduced in v19.7.0 and v18.16.0. Supported on 22, 24 and 26 with different workflows (see the next rows).

node --build-sea=config (blob generation and injection in one step)

1.1 Active development

Added in v25.5.0. Observed: it is available on Node.js 26 and is rejected as a bad option on 24.21.0 and 22.23.3, and the v24.x and v22.x documentation does not list it.

node --experimental-sea-config=config (writes a blob that another tool injects)

1.1 Active development

The older, two-step workflow. It still works on 26, 24 and 22, and the current documentation keeps it for verification and for external injection tools.

sea-config.json fields main, output, executable, disableExperimentalSEAWarning, useSnapshot, useCodeCache, assets

1.1 Active development

useSnapshot and useCodeCache were added in v20.6.0. The main script is CommonJS by default.

mainFormat, useVfs, execArgv, execArgvExtension

1.1 (useVfs: 1.0 Early development)

Documented on the Node.js 26 page. mainFormat and useVfs are not in the v24.x or v22.x pages; execArgv and execArgvExtension are described in both of them. useVfs was added in v26.9.0.

node:sea: isSea(), getAsset(), getAssetAsBlob(), getRawAsset()

1.1 Active development

Added in v21.7.0 and v20.12.0.

sea.getAssetKeys()

1.1 Active development

Added in v24.8.0 and v22.20.0.

node:vfs (vfs.create(), VirtualFileSystem, mount())

1 Experimental

Module added in v26.4.0 and only available with --experimental-vfs; mount() added in v26.9.0. There is no node:vfs on Node.js 24 or 22: --experimental-vfs is a bad option there.

v8.startupSnapshot.setDeserializeMainFunction()

Stable snapshot building, documented in the v8 module

Needed when useSnapshot is true. Building snapshots with --build-snapshot stopped being experimental in v25.4.0 and v24.13.1.

How a SEA works

Node.js does not compile your program to machine code. The build step creates a preparation blob that contains the main script (and optionally assets, a V8 code cache or a heap snapshot) and embeds it in a copy of a node executable, in a format-specific place: a resource named NODE_SEA_BLOB in a Windows PE file, a section of the same name in the NODE_SEA segment of a macOS Mach-O file, and a note in a Linux ELF file. A sentinel string (the NODE_SEA_FUSE_ string quoted in the official page) inside the binary is flipped from 0 to 1 to tell the runtime that something was injected. When the process starts it looks at that fuse: if it is set, the embedded script runs instead of the normal command line handling; otherwise Node.js behaves as usual. The documentation labels this process as subject to change. The ELF, Mach-O and PE details are therefore interesting, but should not be coded against.

flowchart LR src["bundled script
(plus assets)"] --> cfg["sea-config.json"] cfg --> build["node --build-sea
sea-config.json"] node["node binary
(same version)"] --> build build --> blob["preparation blob
(script, assets, cache or snapshot)"] blob --> inject["injection into a copy of node
(done internally)"] inject --> exe["executable"] exe --> sign["sign
(macOS required, Windows optional)"] sign --> run["run on a machine
without Node.js"]
Figure 1. From script to executable with --build-sea

Building with --build-sea

Since Node.js 25.5.0 one command does everything. node --build-sea sea-config.json reads the JSON configuration (paths are resolved relative to the working directory), generates the blob and writes the final executable, so no external injection tool is needed. The node binary used to produce the blob must be the same version as the one that receives it, which is automatically true when you let --build-sea copy the running binary.

Smallest possible build (Node.js 26, macOS arm64)
echo 'console.log(`Hello, ${process.argv[2]}!`);' > hello.js
echo '{ "main": "hello.js", "output": "hello" }' > sea-config.json

node --build-sea sea-config.json
codesign --sign - hello
./hello world

Output (the ExperimentalWarning goes to standard error and can be silenced with disableExperimentalSEAWarning):

Generated single executable /home/me/.nvm/versions/node/v26.10.0/bin/node + sea-config.json -> hello
Hello, world!
(node:83024) ExperimentalWarning: Single executable application is an experimental feature and might change at any time
(Use `hello --trace-warnings ...` to show where the warning was created)

The main script is an ordinary script that has been bundled into one file. Inside it, require() and import can only load built-in modules, because module loading does not read the file system (the section on limitations shows how to lift that). A bundler such as esbuild or Rollup, run before --build-sea, turns an application with dependencies into the single file the build expects; the bundlers themselves are outside this page. The same file, run with plain node, reports a different world: isSea() from node:sea is the supported way to tell the two apart.

The sea-config.json fields

Table 2. Configuration fields (Node.js 26 documentation)
Field Meaning Default and constraints

main

Path of the bundled script that is embedded.

Required. Relative paths are resolved from the working directory.

mainFormat

How the script is interpreted: "commonjs" or "module".

"commonjs". "module" cannot be combined with useSnapshot.

executable

The node binary to copy and inject into.

The current Node.js binary. When it is a different platform, useCodeCache and useSnapshot must be false.

output

Where the generated executable is written.

Required. On Windows the .exe extension is necessary.

disableExperimentalSEAWarning

Silences the experimental warning at start-up.

false

useSnapshot

Runs main at build time and serialises the heap into the blob.

false. See Startup snapshots.

useCodeCache

Compiles main at build time and embeds the V8 code cache.

false. See The V8 code cache.

useVfs

Exposes the assets as a read-only virtual file system. See The virtual file system for assets (useVfs).

false. Cannot be combined with useSnapshot or useCodeCache.

execArgv

Node.js options applied automatically when the executable starts.

Optional. They appear in process.execArgv.

execArgvExtension

Whether the user can add more options: "none", "env" (through NODE_OPTIONS) or "cli" (through --node-options="…​").

"env"

assets

Dictionary from an asset key to the file that is read at build time.

Optional. See Bundling assets and the node:sea API.

Bundling assets and the node:sea API

Anything that is not JavaScript (templates, JSON data, images, a license text) can be bundled through the assets dictionary. The files are read at build time, stored in the blob and retrieved at run time by key with the node:sea module. The module has no effect outside a SEA, except for isSea(), which then returns false.

Table 3. The node:sea functions
Function Returns Notes

isSea()

boolean

Whether the script runs inside a single executable application.

getAsset(key[, encoding])

ArrayBuffer, or a string when an encoding such as 'utf8' is given

Returns a copy. Throws when the key does not exist.

getAssetAsBlob(key[, options])

Blob

options.type sets the MIME type. Throws when the key does not exist.

getRawAsset(key)

ArrayBuffer

Returns the bytes inside the executable without copying. Do not write to it: the section may not be writable or aligned and a write can crash the process.

getAssetKeys()

string[]

Throws when not running inside a SEA. Added in v24.8.0 and v22.20.0.

The following Bookshelf command line tool is an ES module, so the configuration sets "mainFormat": "module". It bundles a catalogue and a banner as assets, locks the runtime options with execArgv and refuses NODE_OPTIONS through "execArgvExtension": "none".

cli.mjs, the bundled entry point
import { isSea, getAsset, getAssetKeys, getAssetAsBlob } from 'node:sea';
import { parseArgs } from 'node:util';

if (!isSea()) {
  console.error('Not running inside a single executable application.');
  process.exit(1);
}

const { values } = parseArgs({
  options: { year: { type: 'string' }, keys: { type: 'boolean' } },
  allowPositionals: true,
});

if (values.keys) {
  console.log(getAssetKeys());
} else {
  console.log(getAsset('banner.txt', 'utf8').trim());
  const books = JSON.parse(getAsset('books.json', 'utf8'));
  const blob = getAssetAsBlob('books.json', { type: 'application/json' });
  console.log(`books.json is ${blob.size} bytes (${blob.type})`);
  for (const book of books) {
    if (!values.year || String(book.year) === values.year) {
      console.log(`#${book.id} ${book.title} (${book.year})`);
    }
  }
  console.log('execArgv:', process.execArgv, 'argv.length:', process.argv.length);
  console.log('import.meta.filename === execPath:', import.meta.filename === process.execPath);
}
sea-config.json with assets and execution arguments
{
  "main": "cli.mjs",
  "mainFormat": "module",
  "output": "bookshelf",
  "disableExperimentalSEAWarning": true,
  "execArgv": ["--max-old-space-size=256"],
  "execArgvExtension": "none",
  "assets": {
    "books.json": "books.json",
    "banner.txt": "banner.txt"
  }
}
Building and running it (Node.js 26)
node --build-sea sea-config.json && codesign --sign - bookshelf
./bookshelf --year 2026
./bookshelf --keys
NODE_OPTIONS=--no-such-flag ./bookshelf --year 2025   # ignored: execArgvExtension is "none"
node cli.mjs                                           # plain node: isSea() is false
Bookshelf CLI: list the books bundled into this executable.
books.json is 133 bytes (application/json)
#2 Bookshelf field guide (2026)
execArgv: [ '--max-old-space-size=256' ] argv.length: 4
import.meta.filename === execPath: true
[ 'books.json', 'banner.txt' ]
#1 Efficient Node.js sampler (2025)
execArgv: [ '--max-old-space-size=256' ] argv.length: 4
import.meta.filename === execPath: true
Not running inside a single executable application.

Two details are worth noting in the output. process.argv.length is 4 because user arguments start at index 2, exactly as with node script.js …​, even though the script is embedded. And in the injected script filename, import.meta.filename and process.execPath all point at the executable itself, never at a source file, so code that locates sibling files with dirname needs the asset API (or the virtual file system below) instead.

With "execArgvExtension": "cli" the executable accepts --node-options="--flag1 --flag2" on its command line, which is the way to pass options that NODE_OPTIONS does not allow; with the default "env" the NODE_OPTIONS environment variable extends the arguments. For a program that is distributed to others the safest choice is "none", because it keeps the environment of the user from changing how the runtime starts.

Startup snapshots

With "useSnapshot": true the main script does not run when the executable starts. It runs once on the build machine, V8 serialises the resulting heap into the blob, and each launch deserialises that heap and calls the function you registered with v8.startupSnapshot.setDeserializeMainFunction(). Work that is expensive but deterministic (parsing a large catalogue, building lookup tables) is paid once. The usual constraints of startup snapshot scripts apply, and mainFormat: "module" cannot be combined with useSnapshot.

snap.js, a snapshot main script
const { setDeserializeMainFunction } = require('node:v8').startupSnapshot;

// Runs once, on the build machine: expensive start-up work is frozen into the snapshot.
const catalogue = Array.from({ length: 1000 }, (_, i) => ({ id: i, title: `Book ${i}` }));
console.log('building snapshot, catalogue size', catalogue.length);

// Runs on every launch, on top of the deserialized heap.
setDeserializeMainFunction(() => {
  console.log('launched with', catalogue.length, 'books preloaded; arg:', process.argv[2]);
});
Building and launching it (Node.js 26)
echo '{ "main": "snap.js", "output": "snap", "useSnapshot": true, "disableExperimentalSEAWarning": true }' > sea-config.json
node --build-sea sea-config.json && codesign --sign - snap && ./snap hi
building snapshot, catalogue size 1000
Generated single executable /home/me/.nvm/versions/node/v26.10.0/bin/node + sea-config.json -> snap
launched with 1000 books preloaded; arg: hi

The first line is printed by the build, not by the launch. Adding "mainFormat": "module" to that configuration fails the build with the message "mainFormat": "module" is not supported when "useSnapshot" is true (observed text).

The V8 code cache

"useCodeCache": true makes the build compile main and embed the V8 code cache, so at launch the engine skips compilation and goes straight to execution. It helps start-up time for large bundles and costs nothing in behaviour, with one documented exception: import() does not work when it is enabled.

Code cache build (Node.js 26)
echo '{ "main": "cc.js", "output": "cc", "useCodeCache": true, "disableExperimentalSEAWarning": true }' > sea-config.json
node --build-sea sea-config.json && codesign --sign - cc && ./cc

The documentation says import() does not work with the code cache. In a quick check on Node.js 26.10.0 a dynamic import('node:os') of a built-in module did succeed from a CommonJS main, so treat the limitation as an upper bound (observed only for a built-in), and do not rely on dynamic import in a cached build. Because a cache or snapshot can only be loaded on the platform where it was compiled, a cross-platform build must keep both useCodeCache and useSnapshot set to false, or the executable may crash on start-up.

Signing on macOS and Windows

Injecting a blob invalidates the signature that ships with the official node binary, so the platforms that check signatures need the result signed again.

Table 4. Signing requirements
Platform What the documentation says Observed here

macOS

Sign the binary after the build, for example with codesign --sign - <file> (an ad-hoc signature).

On Apple silicon, the freshly built executable was killed by the system (exit status 137, no output) until it was signed; after codesign --sign - it ran. Ad-hoc signing is enough to run it locally. Distributing to other Macs also involves your developer identity and notarization (see Apple’s documentation).

Windows

Signing is optional: signtool sign /fd SHA256 sea.exe. It needs a certificate; the unsigned binary still runs.

Not run (no Windows machine). Whether an unsigned executable triggers warnings for end users was not checked here.

Linux

No signing step in the documentation.

Not applicable.

Signing commands
# macOS: ad-hoc signature, enough to run on the build machine
codesign --sign - bookshelf

# Windows (PowerShell): needs a code-signing certificate that you manage outside the repository
signtool sign /fd SHA256 bookshelf.exe

Keep certificates, passwords and notarization credentials in the CI secret store and read them from the environment, never from the repository or the sea-config.json file.

The virtual file system for assets (useVfs)

Reading assets through getAsset() means rewriting every call that used to read a file. Since Node.js 26.9.0 (Stability 1.0, Early development) the assets can instead be exposed as a read-only virtual file system through "useVfs": true, so ordinary node:fs calls and relative require() calls keep working. The mount lives in a reserved namespace that cannot exist on the real file system, so it never shadows a real path, and the injected main script itself is run from the root of the mount: dirname is that root. The mount point is chosen at run time, so code must always build paths from dirname (or import.meta.dirname in an ES module) and never hard-code one.

app.js, reading bundled files with plain fs
const fs = require('node:fs');
const path = require('node:path');
const shout = require('./lib/shout.js');

const config = JSON.parse(fs.readFileSync(path.join(__dirname, 'config.json'), 'utf8'));
console.log('name:', shout(config.name));
console.log('files:', fs.readdirSync(path.join(__dirname, 'public')));
console.log('exists optional:', fs.existsSync(path.join(__dirname, 'optional.json')));
console.log('__dirname inside VFS:', __dirname !== path.dirname(process.execPath));
try {
  fs.writeFileSync(path.join(__dirname, 'public', 'new.txt'), 'x');
} catch (err) {
  console.log('write ->', err.code);
}
sea-config.json with useVfs
{
  "main": "app.js",
  "output": "app",
  "useVfs": true,
  "disableExperimentalSEAWarning": true,
  "assets": {
    "config.json": "config.json",
    "lib/shout.js": "lib/shout.js",
    "public/index.html": "public/index.html"
  }
}
name: BOOKSHELF
files: [ 'index.html' ]
exists optional: false
__dirname inside VFS: true
write -> EROFS

The relative require('./lib/shout.js') was resolved from the bundled assets, readdirSync() listed only the bundled entries and a write failed with EROFS because the file system is read-only. ES module entry points are supported with "mainFormat": "module" (import.meta.dirname is the mount root), and name bundled modules .mjs or bundle the relevant package.json files so their format is detected as on a real disk.

Table 5. Constraints of useVfs
Constraint Detail

No snapshot, no code cache

useVfs cannot be combined with useSnapshot or useCodeCache. Observed on 26.10.0: the build stops with "useVfs" is not supported when "useCodeCache" is true. The code cache limitation is described as an incomplete implementation, not a technical impossibility.

Native add-ons

.node files cannot load from the virtual file system because process.dlopen() needs a real file: write the asset to a temporary file first, as shown in the official page.

Read-only

Write operations fail with EROFS. Persist user data with the real file system under a directory of your choosing, not next to the executable.

Not a security boundary

The node:vfs documentation states the VFS is not a sandbox or permission system and must not be used to isolate untrusted code.

The node:vfs module on its own

node:vfs (Stability 1, Experimental, added in v26.4.0) is the general module behind useVfs: a virtual file system with a node:fs-shaped API, backed by memory by default, which is useful for test fixtures and embedded assets. It is only available under the node: scheme and only when Node.js starts with --experimental-vfs; without the flag the import fails with ERR_UNKNOWN_BUILTIN_MODULE. Mounting an instance with mount() returns a mount point under a reserved namespace; paths below it are served by the virtual provider through the global node:fs, require() and import, and a using declaration unmounts automatically.

v.mjs, a mounted in-memory file system (Node.js 26)
import vfs from 'node:vfs';
import fs from 'node:fs';

using fixture = vfs.create();
fixture.mkdirSync('/templates', { recursive: true });
fixture.writeFileSync('/templates/mail.txt', 'Dear reader');
const mountPoint = fixture.mount();
console.log(fs.readFileSync(`${mountPoint}/templates/mail.txt`, 'utf8'));
console.log('mount point reserved:', mountPoint.startsWith('/dev/null/vfs/'));
Running it
node --experimental-vfs v.mjs
Dear reader
mount point reserved: true
(node:92296) ExperimentalWarning: VirtualFileSystem is an experimental feature and might change at any time
(Use `node --trace-warnings ...` to show where the warning was created)

Besides MemoryProvider the module has RealFSProvider (a directory) and ZipProvider (an archive), and the command line gained --vfs-mount and --vfs-load flags that mount a directory or archive without code; the node:vfs page documents their details, and the mount point format is explicitly subject to change. On Node.js 24 and 22 the module and the flag do not exist, so there the only asset route is the node:sea API. The /dev/null/vfs/ prefix in the example is an observation of one release, not a contract.

Limitations and pitfalls

Table 6. What a SEA cannot or should not do
Area Behaviour

One script

A SEA embeds a single script, CommonJS or (since the mainFormat field) an ES module. Dependencies must be bundled into it or shipped as assets.

Module loading

require() and import load only built-in modules by default. The injected require() has no properties of a normal one except require.main. To load from disk, create a function with module.createRequire() and call it with __filename, which is the executable path.

ES module details

import.meta.url, filename and dirname describe process.execPath, import.meta.main is true, import.meta.resolve is not supported, and import() can load built-ins only.

Same version and platform

The binary receiving the blob must be the same version as the one that produced it. Cross-platform builds must disable the code cache and the snapshot. Native add-ons are not portable either.

Native add-ons

They cannot be require()`d from the bundle. Bundle the `.node file as an asset, write it to a temporary file and load it with process.dlopen(). A known upstream issue crashes add-ons in binaries injected by postject inside a Linux arm64 container.

Size

The executable contains the whole node binary (about 145 MB here on macOS arm64), regardless of how small the script is.

Platform support

CI tests Windows, macOS (arm64 only; x64 is not tested) and Linux (all supported distributions except Alpine, all architectures except s390x). Anything else is unsupported.

Security

The script and assets are embedded unencrypted and can be extracted by anyone who has the file: never embed secrets. Read them from the environment at run time.

Updates

Security fixes in Node.js reach your users only when you rebuild with a patched node and redistribute the file.

Loading a module from disk inside a SEA (CommonJS entry point)
const { createRequire } = require('node:module');
const requireFromDisk = createRequire(__filename);
const plugin = requireFromDisk('./plugins/export-csv.cjs'); // resolved from the executable's directory
console.log(plugin);
try { require('./plugins/export-csv.cjs'); } catch (e) { console.log('plain require ->', e.code); }
Output (Node.js 26), identical when the executable and a plugins directory are copied to another folder
{ name: 'csv' }
plain require -> ERR_UNKNOWN_BUILTIN_MODULE

The pattern is the one from the official page. It was built and run as a SEA, and the plugin file stays outside the executable, which is the opposite of what a self-contained build aims at, so use it sparingly. The plugin uses the .cjs extension so that the format does not depend on a package.json found on disk.

The legacy workflow with --experimental-sea-config and postject

Before --build-sea the build was three steps, and it is still the way to build on Node.js 24 and 22, where --build-sea does not exist. The first step writes a blob (in the configuration output names the blob file instead of the final executable), the second copies the node binary and removes its signature on macOS and Windows, and the third injects the blob with the postject tool from the Node.js organisation. The sentinel fuse string and the Mach-O segment name come from the official page; the example keeps the fuse in an environment variable so that you copy it from that page into your build script once.

Legacy build on Node.js 24.21.0 (macOS arm64; 22.23.3 gave the same result)
# SEA_FUSE: the NODE_SEA_FUSE_... sentinel string quoted in the official documentation
echo '{ "main": "hello.js", "output": "sea-prep.blob", "disableExperimentalSEAWarning": true, "assets": { "banner.txt": "banner.txt" } }' > sea-config.json
node --experimental-sea-config sea-config.json     # writes the blob only

cp "$(command -v node)" hello
codesign --remove-signature hello                  # macOS; on Windows: signtool remove /s hello.exe
npx postject hello NODE_SEA_BLOB sea-prep.blob \
    --sentinel-fuse "$SEA_FUSE" \
    --macho-segment-name NODE_SEA                  # --macho-segment-name only on macOS
codesign --sign - hello
./hello world
Start injection of NODE_SEA_BLOB in hello...
Injection done!
isSea: true | banner: Bookshelf on Node 24 | argv[2]: world

postject is an alpha-versioned package (1.0.0-alpha.6 was the npm release on the day of writing) that you install in your build tooling, never in the application. On Linux the --macho-segment-name option is not used, and on Windows the blob target is a .exe copy of node.

What changed recently

Table 7. Single executable applications by release
Version Change

v20.6.0

useSnapshot and useCodeCache configuration fields.

v21.7.0, v20.12.0

The node:sea asset API: getAsset(), getAssetAsBlob(), getRawAsset() and isSea(), plus the assets field.

v22.20.0, v24.8.0

sea.getAssetKeys().

v25.5.0

node --build-sea: a one-step build that no longer needs postject. Not backported: it is a bad option on 24.21.0 and 22.23.3 (observed).

Node.js 26 line

mainFormat (ES module entry points) and useVfs appear only in the Node.js 26 page. execArgv and execArgvExtension are described in the v24.x and v22.x pages too. The exact release that introduced each one was not verified here, so check the release notes of your version before relying on a field in a 24 or 22 build.

v26.4.0

The experimental node:vfs module and --experimental-vfs.

v26.9.0

useVfs in sea-config.json and vfs.mount().

v26.10.0

vfs.registerProvider() and the --vfs-mount flag, used outside SEAs.

Where the books are outdated

Efficient Node.js (Samer Buna) and Node.js Projects (Jonathan Wexler) are written for a time when shipping a Node.js program meant installing Node.js on the target machine or reaching for a third-party packager. Neither describes the built-in single executable support, so a reader who stops there will miss that the runtime can now produce a standalone binary with node --build-sea, bundled assets through node:sea, optional start-up snapshots and, on the newest line, a virtual file system. The workflow with postject that circulates in many tutorials is the older two-step form, and it is no longer the recommended path on Node.js 25.5 and later. Use the books for the concepts (module systems, start-up cost, why bundling helps) and this page for the current commands.

Summary

  • A SEA is the node binary plus an injected blob: no Node.js on the target machine, but about 145 MB per file, and Stability 1.1 (Active development).

  • On Node.js 25.5 and later, build with node --build-sea sea-config.json; on 24 and 22 use --experimental-sea-config and postject.

  • Bundle the application into one script, set mainFormat for ES modules, and ship data files through assets and node:sea (or useVfs on Node.js 26.9 and later).

  • Sign the result on macOS (required to run on Apple silicon) and, where possible, on Windows; build on the target platform when using a snapshot or code cache.

  • Never embed secrets, pin the Node.js version, and rebuild when Node.js ships security fixes.