Browse docs All docs
Docs / Working on a Node app
Guidedeploymill://guides/stack/node

Working on a Node app

Reference for an agent modifying a DeployMill-managed Node app scaffolded from the node template. Read it before editing app code, the Dockerfile, or package.json: it covers the layout DeployMill assumes, the contract that makes the deploy come up, and the modifications that work cleanly with the rest of the system. For library choices (not requirements), read deploymill://guides/conventions/node alongside this.

What DeployMill scaffolds

  • src/index.js: Hono server using @hono/node-server. Listens on process.env.PORT || 3000. Exposes / (HTML landing) and /healthz ({ok: true}).
  • package.json: type: "module"; the start script runs node src/index.js. Hono + @hono/node-server are the only deps. The packageManager field pins the pnpm version corepack uses. Don't strip it, or corepack will download the latest pnpm, which can require a newer Node major than the base image ships.
  • Dockerfile: multi-stage build using node:22-alpine + pnpm via corepack. The build stage installs deps (with dev deps) and copies sources; the runtime stage carries node_modules, package.json, and src/ only. EXPOSE 3000, CMD ["node", "src/index.js"].

The platform contract

Three things must hold for the deploy to come up:

  1. Bind to 0.0.0.0, not localhost. @hono/node-server's default binds to all interfaces. Keep it that way. Localhost-only binding makes the container unreachable from the platform's router.
  2. Listen on process.env.PORT (default 3000). The template already does this; don't hard-code 3000 if you start reading the env elsewhere.
  3. EXPOSE <port> in the Dockerfile must match the listen port. The scaffold pins both to 3000.

Health checks

/healthz returns {ok: true}. The platform doesn't require this route (anything on the published port is routable), but it's the convention DeployMill uses for liveness checks. If you remove it, leave a replacement at a stable path.

Common modifications

  • Add a dependency: edit package.json dependencies, then push_files. The build runs pnpm install --prod=false so dev deps install too.
  • Add a dependency with a native (C/C++) addon (better-sqlite3, bcrypt, sharp, …): the scaffold's slim base image has no compiler, and pnpm blocks addon build scripts by default, so an unguarded install either fails the build (gyp ERR) or, worse, succeeds and crash-loops at runtime with a missing-bindings error. Install a build toolchain in the build stage and either install with npm ci or allow-list the package in pnpm.onlyBuiltDependencies. See deploymill://guides/conventions/node → "Native (C/C++) addon dependencies" for the full pattern.
  • Switch to TypeScript: add typescript, @types/node, and a tsconfig.json; change the start script to compile-then-node (or use tsx). Update the Dockerfile to compile in the build stage and copy dist/ into the runtime stage. Keep the runtime image free of dev deps.
  • Swap Hono for Express/Fastify: keep the PORT contract and /healthz. The only Hono-specific code lives in src/index.js.
  • Add static files: serve them from the same Node process (Hono's serveStatic) so they live behind the same port and routing.
  • Run a headless background worker (queue consumer, scheduler, etc.): use start_project({ stack: "node", workload: "worker" }). That scaffolds the Node worker starter: no EXPOSE, no domain, a stdout heartbeat as the starter. Replace the heartbeat loop with real recurring work. The worker holds an active-app quota slot while running. For an in-process background task that lives inside the same container as your HTTP server, use setInterval / a process supervisor instead.

Persistent state

The container filesystem is ephemeral. To keep files across deploys (uploads, caches, an on-disk index), add a mount in .deploymill/project.json and read/write under that path. For relational app data, prefer the managed database instead of a volume. See deploymill://guides/storage.

Database

For Postgres, declare database: { engine: "postgres", provider: "deploymill" } (the internal-Postgres house default, no external account) in .deploymill/project.json, run reconcile_project, then fetch deploymill://guides/database/node and follow it. Don't roll your own connection pool. The guide gives you the one that won't exhaust the database's connection limit.

The engine is postgres; the provider is who operates it (deploymill, neon, or supabase). A Neon-backed app runs the same Postgres engine as the house default; only the operator differs. Neon and Supabase need their own connected account.

What NOT to do

  • Don't app.listen(3000, "localhost"). Bind to all interfaces.
  • Don't write to the container filesystem expecting persistence. Use a mount.
  • Don't run pnpm install at container start. It's a build-stage thing. The runtime stage should boot in seconds.
  • Don't hard-code secrets in src/. Use set_env_vars and read from process.env.

Debugging a failed deploy

  • list_deployments shows the most recent deploy and its status.
  • Build + container stdout is available via get_logs({ applicationId }). For a running app's stdout/stderr, call get_logs({ applicationId, source: "runtime" }). See deploymill://guides/logs for the full loop.
  • "App is not responding" usually means: bound to localhost, wrong port, or the process crashed at startup. Run get_logs({ applicationId }) to read the build output.
  • Build was green but the container crash-loops with Could not locate the bindings file / invalid ELF header: a native (C/C++) addon dependency's install script was skipped (pnpm blocks them by default), so its compiled .node binary is missing at runtime. See deploymill://guides/conventions/node → "Native (C/C++) addon dependencies".