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 onprocess.env.PORT || 3000. Exposes/(HTML landing) and/healthz({ok: true}).package.json:type: "module"; thestartscript runsnode src/index.js. Hono +@hono/node-serverare the only deps. ThepackageManagerfield 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 usingnode:22-alpine+ pnpm via corepack. The build stage installs deps (with dev deps) and copies sources; the runtime stage carriesnode_modules,package.json, andsrc/only.EXPOSE 3000,CMD ["node", "src/index.js"].
The platform contract
Three things must hold for the deploy to come up:
- Bind to
0.0.0.0, notlocalhost.@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. - Listen on
process.env.PORT(default 3000). The template already does this; don't hard-code 3000 if you start reading the env elsewhere. 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.jsondependencies, thenpush_files. The build runspnpm install --prod=falseso 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 withnpm cior allow-list the package inpnpm.onlyBuiltDependencies. Seedeploymill://guides/conventions/node→ "Native (C/C++) addon dependencies" for the full pattern. - Switch to TypeScript: add
typescript,@types/node, and atsconfig.json; change thestartscript to compile-then-node (or usetsx). Update the Dockerfile to compile in the build stage and copydist/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 insrc/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: noEXPOSE, 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, usesetInterval/ 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 installat container start. It's a build-stage thing. The runtime stage should boot in seconds. - Don't hard-code secrets in
src/. Useset_env_varsand read fromprocess.env.
Debugging a failed deploy
list_deploymentsshows the most recent deploy and its status.- Build + container stdout is available via
get_logs({ applicationId }). For a running app's stdout/stderr, callget_logs({ applicationId, source: "runtime" }). Seedeploymill://guides/logsfor 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.nodebinary is missing at runtime. Seedeploymill://guides/conventions/node→ "Native (C/C++) addon dependencies".