Browse docs All docs
Docs / Recommended libraries & conventions for a Node app
Guidedeploymill://guides/conventions/node

Recommended libraries and conventions for Node

You are an agent about to write or extend application code in a DeployMill-managed Node app. This guide is DeployMill's paved road: the libraries and patterns known to work with the platform's constraints, plus where DeployMill has no opinion and you should defer to the user or the repo.

Read it together with deploymill://guides/stack/node, which documents the platform contract, the things that make the deploy succeed or fail. This guide is about choices. The stack guide is about requirements.

How to read these recommendations

Recommendations come in three strengths. Treat them differently:

  • Contract (must). Lives in deploymill://guides/stack/node, not here. Violate it and the deploy breaks (bind to 0.0.0.0, listen on $PORT, ephemeral filesystem, slim runtime stage).
  • Paved road (should). The defaults below. They're not arbitrary. Every one is either already in the template DeployMill scaffolds for you or already used by the database playbook (deploymill://guides/database/node). Following them keeps the app consistent with what the rest of the tooling assumes. You can deviate. Each row says when that's reasonable and what you must preserve if you do.
  • Taste (DeployMill has no opinion). Formatting, test runner, folder layout beyond the template. DeployMill is an orchestration layer, not a framework, so it won't dictate these. Defer to the user, or to the repo's .deploymill/conventions.md if one exists (see the bottom of this guide).

Rule of thumb: the closer a choice sits to a platform constraint, the more weight the recommendation carries. A pooled DB client is a strong recommendation because Neon caps connections. A formatter is no recommendation at all.

Paved road

These are the choices the node template and the database guide already commit to. Stay on them unless you have a concrete reason.

ConcernDefaultWhy (tie to the platform)When to deviate
RuntimeNode 22 (node:22-alpine)The scaffolded Dockerfile's base image.Only with a matching base-image bump in the Dockerfile.
Module systemESM ("type": "module")The template sets it, and migrations and all .js are loaded as ESM (CommonJS exports.up throws, see the database guide).Rarely. If you switch, fix every .js including migrations.
Package managerpnpm via corepack (pinned packageManager)The Dockerfile uses corepack, and the pin keeps pnpm on a version compatible with the Node base. Don't strip the field.A dependency with a native (C/C++) addon (better-sqlite3, bcrypt, sharp, …). pnpm blocks addon build scripts by default, so the install silently produces an unusable binary. See Native (C/C++) addon dependencies below.
Web frameworkHono + @hono/node-serverWhat the template scaffolds. Its default binds to all interfaces, satisfying the 0.0.0.0 contract for free.Existing Express/Fastify app, so keep the PORT contract and /healthz.
Postgres clientpg (^8), one pooled module at module scopeThe database guide's pool respects Neon's connection limit. A per-request pool exhausts it.Don't.
Migrationsnode-pg-migrate (^7), programmatic runner with noLock, run on container startNeon's pooled endpoint is pgbouncer txn-mode, so the default session lock hangs. The guide's runner handles this.Don't.
Health routes/healthz (liveness), /db (DB reachability)DeployMill's liveness convention and the deploy probePath target.Keep a stable replacement path if you move them.

What the paved road steers you away from (from the database guide's "What NOT to do"): don't reach for a heavyweight ORM (Prisma, TypeORM) unless the user explicitly asks. They add build steps, generated clients, and coupling that work against the "scaffolded and simple" posture. Plain pg + node-pg-migrate covers most scaffolded apps.

When the user needs a capability the template doesn't ship

These aren't in the scaffold, so there's no forced default. The picks below are recommended-but-optional: reach for them when the user asks for the capability, and feel free to substitute if they have a preference. Each one is tied to something DeployMill already gives you.

  • Auth → Better Auth. It's a SQL-backed auth library, so it slots onto the managed Postgres you already provision via database: { engine: "postgres", provider: "deploymill" } (the house default), no extra infra. (It's also what DeployMill itself runs on.) If the user wants a hosted IdP (Clerk, Auth0) instead, that's fine, just remember host-pinned callback URLs must be set per-environment via set_env_vars (and per-preview via create_preview envOverrides, see deploymill://guides/previews). Read deploymill://guides/auth before wiring auth. It covers the platform facts that trip up login on deploy/preview (sessions in Postgres, host-pinned base URL, secrets via the vault, HTTPS-vs-secure-cookies).
  • Input validation → zod. Widely used, zero runtime infra, pairs naturally with Hono handlers. Optional.
  • TypeScript. Supported. The stack guide's "Switch to TypeScript" section has the Dockerfile changes. Add @types/pg if you've wired up the database.

Native (C/C++) addon dependencies

Some packages aren't pure JavaScript. They ship a compiled native addon (a .node binary) that's either downloaded prebuilt or compiled from source by node-gyp at install time. Common ones: better-sqlite3 (the SQLite driver the database guide recommends), bcrypt, sharp, node-canvas, anything with a binding.gyp. Two things on the paved road break these, and they fail in different ways, so check both.

Why it bites (and why the failure is confusing):

  1. Slim base images have no compiler. node:NN-slim (and a stripped Alpine) don't ship python3 / make / g++. When no prebuilt binary matches the image's OS + libc, node-gyp falls back to compiling and dies with gyp ERR! find Python / g++: not found. This one fails the build loudly.
  2. pnpm blocks dependency build scripts by default. Since pnpm v7+, a dependency's lifecycle scripts (postinstall / the prebuild-install/node-gyp step that fetches or links the binary) do not run unless the package is allow-listed. So even when a prebuilt binary is available, the script that puts the .node file in place never fires. The build looks green, then the app crashes at runtime with Could not locate the bindings file / invalid ELF header. This is the trap: a "successful" deploy that's dead on boot.

The fix, both halves, in the Dockerfile's build stage:

  • Install the toolchain (so source compilation works when no prebuilt matches). Put it in the build stage only. A multi-stage runtime stage carries just the already-built node_modules, so the final image stays slim and toolchain-free.
    • Debian (node:NN-slim): RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
    • Alpine (node:NN-alpine): RUN apk add --no-cache python3 make g++
  • Let the addon's install script actually run. Pick one:
    • Simplest: install with npm for projects that have a native addon. npm ci (or npm install) runs dependency lifecycle scripts by default, so the prebuilt-binary download / source compile Just Works. This is exactly why DeployMill's auto-synthesized Dockerfile uses npm ci rather than pnpm. If you're synthesizing or hand-writing the Dockerfile, prefer npm ci here.
    • Staying on the pnpm paved road, allow-list the package so pnpm runs its build script:
      // package.json
      "pnpm": { "onlyBuiltDependencies": ["better-sqlite3"] }

      (equivalently, pnpm approve-builds interactively, or an .npmrc entry). Without this the build is green and the addon is silently missing at runtime.

One more gotcha: match build-stage and runtime base images. A binary compiled or prebuilt for Debian/glibc won't load on Alpine/musl (and vice-versa). If you build on node:NN-slim, run on node:NN-slim. Don't compile on -slim and copy node_modules into an -alpine runtime stage.

Done looks like: the build installs without gyp ERR, and the running container can require() the addon (e.g. for better-sqlite3, the /db health route in the database guide returns OK rather than crashing the process). If the build passed but the app crash-loops with a bindings error, you hit gotcha #2: pnpm skipped the install script.

Things DeployMill has no opinion on

Don't impose a default here. If the user hasn't said and there's no .deploymill/conventions.md, pick the least surprising option and move on. These have no platform consequence:

  • Code formatting / linting (Prettier, ESLint, Biome).
  • Test framework (node:test, Vitest, Jest).
  • Folder structure beyond what the template scaffolds.
  • Logging library, naming conventions.

Project-level overrides: .deploymill/conventions.md

A repo (or the user) may pin its own house style. Before applying the defaults above, check for a .deploymill/conventions.md file in the repo (read it with get_file). If present, treat it as overriding this guide wherever the two disagree. It's the team's explicit choice and wins over DeployMill's generic paved road. The platform contract in the stack guide still holds regardless. A project can't opt out of binding to $PORT.

Note this is a convention for you (the agent) to honor, not a platform-enforced config: DeployMill does not parse or apply .deploymill/conventions.md the way it reconciles .deploymill/project.json (which reconcile_project reads and acts on). It's a plain note that lives in the repo, versions with the code, and travels with the project, but nothing happens unless an agent reads it and follows it.