Shipping native binaries with a Function
The default deploy bundles source into a single index.mjs with esbuild. A compiled binary cannot be inlined into that file, so it has to ship as a separate file in the deploy archive. There are three ways to put it there. Pick by what the binary is:
| The binary is | Use |
|---|---|
An npm package backed by a Node-API addon (sharp, most @napi-rs/* packages) |
externalPackages |
A file your own build step produces: a .node addon, a .so, a .wasm, model weights |
bundler: "none" or a custom bundler |
A standalone executable you spawn (ffmpeg, a Go or Rust CLI) |
A custom bundler or "none", plus a copy to /run at startup |
The examples use top-level functions, which needs neon CLI 4.20 or newer and @neon/config 1.6 or newer.
The runtime a binary lands on
- linux, arm64, glibc, Node.js 24. A binary built for macOS or x64 cannot load there.
- The archive is extracted to
/opt/function, a read-only filesystem.import.meta.urlin the rootindex.mjspoints there, sonew URL("./bin/tool", import.meta.url)resolves to/opt/function/bin/tool. - Every file arrives with mode
644. Unix permission bits in the zip are dropped on extraction, andchmodin place fails withEROFS. A.nodeor.soloads fine (it isdlopened, not executed). Spawning a file from/opt/functionfails withEACCES. /tmpis writable and mountednoexec: a copied binary still fails withEACCESafterchmod 755./runis a writable tmpfs that allows exec (about 990 MiB, backed by the function's 2048 MiB of memory).
The filesystem layout is observed behavior, not documented by Neon. Re-check it if spawning starts failing.
externalPackages: npm packages with a Node addon
// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
resize: {
name: "Resize",
source: "./src/resize.ts",
externalPackages: ["sharp"],
},
},
});esbuild leaves import sharp from "sharp" unresolved. At deploy, the CLI installs the version of sharp from your project with npm install --cpu=arm64 --os=linux --libc=glibc --ignore-scripts into a temp directory, traces the files it reaches with @vercel/nft, and copies them into the archive under node_modules/ with the tree layout intact. Only the installed version is read from your node_modules; the shipped files come from the temp install, and your node_modules is not modified.
The deploy fails with a named error when:
- the package is not installed in your project (no version to pin)
- the declared package itself does not install for linux-arm64 glibc (
EBADPLATFORM) - a staged
.nodeor.sois not an AArch64 ELF binary npmis not onPATH- the archive exceeds the size limits
It does not fail when a platform-specific optional dependency is silently skipped, or when a package that compiles from source at install time ships no binary (the staging install runs with --ignore-scripts). Those deploy and then fail at invoke. Use packages that publish a linux-arm64 glibc prebuild (sharp does), and invoke the deployed function once to confirm the addon loads.
A deploy and neon dev print an advisory warning for any bundled package that carries native code and is not declared. A package with a working JavaScript fallback (ws with bufferutil) triggers it too and needs no change. Do not silence it with { name, includeFiles: false }: that externalizes the package and ships nothing, so a reached import throws Cannot find module on every invoke. includeFiles: false is only for an import the function never evaluates.
Under neon dev, the package is only kept out of the bundle and resolves from your own node_modules for your host.
Pros: one line in neon.ts; the target-platform install, file tracing, arch check, and size check are automatic; local dev uses your host build.
Cons: only for npm packages that publish a linux-arm64 glibc prebuild; transitive pins, overrides, and patches from your lockfile are not carried into the staged install; esbuild bundler only (externalPackages with bundler: "none" or a function fails validation).
bundler: "none": ship a prebuilt directory
Run your own build, then point source at its output. The directory root must contain index.mjs or index.js. Everything else in the directory ships as-is:
dist/fn/
index.mjs # built by your step, reads ./native/addon.node
native/addon.node// neon.ts
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
api: { name: "API", source: "./dist/fn", bundler: "none" },
},
});npm run build:fn && neon deploy --env .env.local
neon functions deploy api --src dist/fn --no-bundle # same thing, without neon.tsneon deploy does not run your build step. Run it first, or use a custom bundler to keep the build inside neon deploy.
Pros: works with any build tool or framework output (.mastra/output is the common one); full control over the archive layout.
Cons: no architecture check, so a binary built for your laptop deploys and then fails at invoke; the build step is yours to keep in sync with neon deploy; TypeScript cannot ship unbundled.
Custom bundler: return the file map
An inline function in neon.ts returns archive paths mapped to bytes. neon deploy zips the map, and neon dev serves the same map locally:
// neon.ts
import { readFile } from "node:fs/promises";
import { build } from "esbuild";
import { defineConfig } from "@neon/config/v1";
export default defineConfig({
functions: {
transcode: {
name: "Transcode",
source: "./src/transcode.ts",
bundler: async (fn) => {
const out = await build({
entryPoints: [fn.source],
bundle: true,
platform: "node",
format: "esm",
write: false,
outfile: "index.mjs",
// CommonJS dependencies call require(); ESM output has none in scope.
banner: {
js: "import{createRequire}from'module';const require=createRequire(import.meta.url);",
},
});
return {
"index.mjs": out.outputFiles[0].contents,
"bin/ffmpeg": new Uint8Array(
await readFile(new URL("./vendor/linux-arm64/ffmpeg", import.meta.url)),
),
};
},
},
},
});esbuild must be a dependency of your project. The map needs index.mjs or index.js at the root.
Pros: the build lives in neon.ts, so neon deploy and neon dev always build the same thing; any file from anywhere can go in the archive.
Cons: no architecture check; externalPackages cannot be combined with it, so an npm addon has to be copied into the map by hand, with its node_modules/ layout.
Standalone executables
Ship the executable with a custom bundler or bundler: "none", then copy it to /run and mark it executable once per isolate:
// src/transcode.ts
import { execFile } from "node:child_process";
import { chmod, copyFile, mkdir } from "node:fs/promises";
import { promisify } from "node:util";
const run = promisify(execFile);
let ffmpeg: Promise<string> | undefined;
function ffmpegPath(): Promise<string> {
if (process.env.FFMPEG_PATH) return Promise.resolve(process.env.FFMPEG_PATH);
ffmpeg ??= (async () => {
await mkdir("/run/bin", { recursive: true });
await copyFile(new URL("./bin/ffmpeg", import.meta.url), "/run/bin/ffmpeg");
await chmod("/run/bin/ffmpeg", 0o755);
return "/run/bin/ffmpeg";
})();
return ffmpeg;
}
export default {
async fetch() {
const { stdout } = await run(await ffmpegPath(), ["-version"]);
return new Response(stdout);
},
};neon dev runs this code on your machine, where /run may not exist and a linux-arm64 binary cannot execute. Point it at a host install from the shell, and leave FFMPEG_PATH out of the function's deployed env:
FFMPEG_PATH="$(command -v ffmpeg)" neon devThe binary must be statically linked, or its shared libraries must exist in the runtime image. The copy costs memory: /run is RAM-backed and counts against the function's 2048 MiB.
Size limits
bundler: "none", a custom bundler, and externalPackages staging check the archive before upload:
| Limit | Value |
|---|---|
| Compressed zip | 10 MiB |
| Total uncompressed bytes | 64 MiB |
| Files in the archive | 4,096 |
Exceeding one fails the deploy. The byte-limit errors list the four largest files. Check an executable's size before building around it: a single static binary can use most of the 64 MiB.