← Files NeonARCHIVED FILE

skills/neon-functions/references/native-binaries.md

9.17 KB · Oct 4, 2026 · 18:02 UTC

↓ Download file

See the change to this file →

# 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`](#externalpackages-npm-packages-with-a-node-addon) |
| A file your own build step produces: a `.node` addon, a `.so`, a `.wasm`, model weights    | [`bundler: "none"`](#bundler-none-ship-a-prebuilt-directory) or a [custom `bundler`](#custom-bundler-return-the-file-map) |
| A standalone executable you spawn (`ffmpeg`, a Go or Rust CLI)                              | A custom `bundler` or `"none"`, plus [a copy to `/run` at startup](#standalone-executables) |

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.url` in the root `index.mjs` points there, so `new 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, and `chmod` in place fails with `EROFS`. A `.node` or `.so` loads fine (it is `dlopen`ed, not executed). Spawning a file from `/opt/function` fails with `EACCES`.
- `/tmp` is writable and mounted `noexec`: a copied binary still fails with `EACCES` after `chmod 755`.
- `/run` is 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

```typescript
// 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 `.node` or `.so` is not an AArch64 ELF binary
- `npm` is not on `PATH`
- the archive exceeds the [size limits](#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:

```text
dist/fn/
  index.mjs        # built by your step, reads ./native/addon.node
  native/addon.node
```

```typescript
// neon.ts
import { defineConfig } from "@neon/config/v1";

export default defineConfig({
  functions: {
    api: { name: "API", source: "./dist/fn", bundler: "none" },
  },
});
```

```bash
npm run build:fn && neon deploy --env .env.local
neon functions deploy api --src dist/fn --no-bundle   # same thing, without neon.ts
```

`neon deploy` does not run your build step. Run it first, or use a [custom `bundler`](#custom-bundler-return-the-file-map) 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:

```typescript
// 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:

```typescript
// 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`:

```bash
FFMPEG_PATH="$(command -v ffmpeg)" neon dev
```

The 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.

SHA-256: 04d39d8dc2e822e6e2189752ae01fdff5d85f175059b6209df1c77e105941ae0