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:24-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 the port the container publishes — 3000 in the scaffold. DeployMill does not inject a PORT env var into the container, so the template's process.env.PORT || 3000 resolves to the || 3000 fallback at runtime; reading process.env.PORT is a local-dev convenience, not a platform-supplied value. Set PORT yourself with set_env_vars if you want that indirection.
  3. EXPOSE <port> in the Dockerfile must match the listen port. That EXPOSE line is what DeployMill reads to publish the container port (overridable by port in .deploymill/project.json). The scaffold pins both to 3000; change one and you must change the other.

Health checks

/healthz returns {ok: true}, and the platform probes it — it's the default health path (DEFAULT_HEALTH_PATH), used unless health.path in .deploymill/project.json or a probePath argument to deploy overrides it. It's graded strictly: only a 200 counts as healthy; any other status, a connection error, or a timeout is unhealthy, and on an app armed with rollback: "auto" that triggers an automatic revert of the deploy. A 404 at the health path is the one soft case — the probe falls back to a lenient / check — but don't lean on that. If you move the route, point health.path at the new one; keep whatever you leave behind returning a real 200 only when the app is actually healthy.

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 bind/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" } 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.

There is no database provider to choose: DeployMill runs the Postgres itself, so there is no vendor account to connect and nothing to sign up for. engine is the only axis you set, and postgres is its only selectable value.

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