Running Node.js in Docker: one-off scripts, --watch for development, and shutting down properly
Docker is a convenient way to run Node.js without installing it, to try several Node versions side by side, and to ship an app as an image. Getting a script to run is easy. Getting a server to stop properly is where most setups go wrong, and it's what this post spends most of its time on.
Everything below was run on the official node:24-slim and node:26-slim images with Docker 29. One note on the timings: the test machine's Docker Desktop was configured with a non-default 1-second stop timeout, so every docker stop measurement passes Docker's documented default of 10 seconds explicitly.
One-off scripts
Mount the current folder, make it the working directory, and run a command. --rm deletes the container afterwards:
$ docker run --rm -v "$PWD":/app -w /app node:24-slim node index.mjs
It works! Node v24.21.0 on linux/arm64The same approach checks a script against several Node versions without a version manager:
$ for v in 22 24 26; do docker run --rm -v "$PWD":/app -w /app node:$v-slim node index.mjs; done
It works! Node v22.23.3 on linux/arm64
It works! Node v24.21.0 on linux/arm64
It works! Node v26.10.0 on linux/arm64npm install, npx and test runners work the same way. On Linux hosts, files the container creates in the mounted folder (such as node_modules) are owned by root. Add -u "$(id -u):$(id -g)" to create them as your own user. Which image tag to choose (slim, alpine, full or distroless) is covered in Node.js Docker images: full vs slim vs alpine vs distroless.
Development: --watch through a bind mount
Node's built-in watch mode restarts the process when a file changes, and it works through a bind mount. I tested it on Docker Desktop for Mac:
$ docker run --rm -it --init -p 3000:3000 -v "$PWD":/app -w /app node:24-slim node --watch server.mjs
listening on :3000
Change detected in '/app/server.mjs'
Restarting 'server.mjs'
listening on :3000Editing the file on the host changed the response from version 1 to version 2 about a second later, with no nodemon needed. The --init flag is there for the reason the next section explains.
Why Ctrl+C does nothing: Node as PID 1
Run a plain HTTP server as the container's command and try to stop it:
import { createServer } from 'node:http';
const server = createServer((req, res) => res.end(`Hello from ${process.env.HOSTNAME}\n`));
server.listen(3000, () => console.log(`listening on :3000 as PID ${process.pid}`));Inside the container, that node process is PID 1. The Linux kernel doesn't apply default signal behaviour to PID 1, so unless the process installs its own handlers, SIGINT (Ctrl+C) and SIGTERM (docker stop) are simply ignored. The Node.js Docker team puts it bluntly: "Node.js was not designed to run as PID 1." Measured with Docker's default 10-second stop timeout:
| Container command | Ctrl+C (SIGINT) | docker stop | Exit code |
|---|---|---|---|
node server.mjs | ignored, keeps running | 10.2 s, then SIGKILL | 137 |
--init + node server.mjs | exits | instant | 130 / 143 |
node graceful.mjs (own handlers) | graceful exit | instant, graceful | 0 |
npm start | ignored, keeps running | 0.7 s, with an npm error … signal SIGTERM dump | 1 |
Node 26 gave the same result for the plain case: ignored, then killed after 10.2 seconds. So a server without handlers turns every deploy into a 10-second wait followed by a hard kill, and in-flight requests are cut off mid-response. The fixes:
--init(orinit: truein Compose) runs a tiny init process (docker-init, which is tini) as PID 1. It forwards signals to Node, which is no longer PID 1, so the default "exit on SIGINT/SIGTERM" behaviour applies.- Handle the signals yourself. This is needed anyway if you want a graceful shutdown, see below.
- Don't use
npm startas the container command. npm sits between Docker and Node: Ctrl+C was ignored entirely, anddocker stopended in an npm error and exit code 1. PutCMD ["node", "server.mjs"]in the Dockerfile.
Graceful shutdown, and the keep-alive trap
"Graceful" means: stop accepting new connections, let running requests finish, then exit before Docker's SIGKILL deadline. server.close() does the first two, but here's a version that looks right and still fails. It's the same as below, minus the shuttingDown flag.
The test: a client opens a keep-alive connection, starts a 3-second request on it, and the container gets docker stop half a second later.
# without the Connection: close fix
0.0s fast: 200 Hello from c63b62281450
0.5s docker stop…
3.0s slow: 200 Hello from c63b62281450
8.7s docker stop returned
--- container log
SIGTERM received: no new connections, waiting for in-flight requests…
still busy after 8s, forcing exit
exit 1The slow request finished at 3.0 s, but the server didn't exit for another five seconds and needed the force-exit timer. The reason: when the response ended, its socket went back into keep-alive. server.close() closes the connections that are idle when it's called (Node 19 and later), but a connection that becomes idle afterwards stays open until keepAliveTimeout expires, which is 5000 ms by default. Browsers, load balancers and HTTP agents all use keep-alive, so this happens in real deployments, not just in tests.
The fix: once shutdown starts, tell clients not to reuse the connection.
import { createServer } from 'node:http';
import { setTimeout as sleep } from 'node:timers/promises';
let shuttingDown = false;
const server = createServer(async (req, res) => {
if (req.url === '/slow') await sleep(3000); // a request that's still running at shutdown
// Once we're shutting down, don't let this socket go back to keep-alive.
if (shuttingDown) res.setHeader('connection', 'close');
res.end(`Hello from ${process.env.HOSTNAME}\n`);
});
server.listen(3000, () => console.log(`listening on :3000 as PID ${process.pid}`));
function shutdown(signal) {
console.log(`${signal} received: no new connections, waiting for in-flight requests…`);
shuttingDown = true;
// close() stops accepting connections and closes the keep-alive sockets
// that are idle right now (Node 19+). The callback runs once the rest are gone.
server.close(() => {
console.log('all requests finished, exiting');
process.exit(0);
});
// Docker sends SIGKILL after 10s by default; give up a little before that.
setTimeout(() => {
console.log('still busy after 8s, forcing exit');
process.exit(1);
}, 8000).unref();
}
process.on('SIGTERM', shutdown);
process.on('SIGINT', shutdown);# with the fix
0.0s fast: 200 Hello from 1c56e0374763
0.5s docker stop…
3.0s slow: 200 Hello from 1c56e0374763
3.2s docker stop returned
--- container log
SIGTERM received: no new connections, waiting for in-flight requests…
all requests finished, exiting
exit 0The in-flight request completes, the connection closes, and the process exits cleanly 0.2 seconds later. The 8-second fallback stays as a safety net: if a request hangs, exit with code 1 on your own terms instead of being SIGKILLed at 10 seconds. If requests legitimately take longer, raise both this timer and Docker's --stop-timeout.
A production Dockerfile
FROM node:24-slim
ENV NODE_ENV=production
WORKDIR /app
# Dependencies first: this layer is cached until package*.json changes.
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY . .
# The official image ships an unprivileged "node" user. Use it.
USER node
EXPOSE 3000
# Slim images have no curl, but Node has fetch.
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s \
CMD ["node", "-e", "fetch('http://localhost:3000/').then((r) => process.exit(r.ok ? 0 : 1), () => process.exit(1))"]
# Run node directly (not npm start) so it receives SIGTERM.
CMD ["node", "server.mjs"]With a .dockerignore so that your local node_modules and secrets don't end up in the image:
node_modules
npm-debug.log
.git
.env
Dockerfile
.dockerignore$ docker build -t hello-docker .
$ docker run -d --init --name hello -p 3000:3000 hello-docker
$ docker ps --format '{{.Names}} {{.Status}}'
hello Up 12 seconds (healthy)
$ docker exec hello sh -c 'whoami; cat /proc/1/cmdline | tr "\0" " "'
node
/sbin/docker-init -- docker-entrypoint.sh node server.mjs
$ docker stop hello # 0.2 s
$ docker logs hello
listening on :3000 as PID 7
SIGTERM received: no new connections, waiting for in-flight requests…
all requests finished, exiting- Layer order: copying
package*.jsonand runningnpm cibefore copying the source means code changes don't reinstall dependencies. USER node: the official images include an unprivilegednodeuser, andwhoamiconfirms the app isn't running as root.- The healthcheck uses Node's built-in
fetch, becauseslimimages don't include curl or wget. Docker marks the container(healthy)once it passes. --initat run time: PID 1 isdocker-initand Node runs as PID 7. Combined with the signal handlers,docker stoptook 0.2 seconds and exited cleanly.
The old docker attach / docker restart workflow from the first version of this post still works, but docker logs -f is the usual way to follow output, and an orchestrator (Compose, Kubernetes, ECS) handles restarts with a restart policy.
Sources & further reading
- Docker and Node.js best practices (nodejs/docker-node) (PID 1,
--init, not using npm as the command) - Docker docs: docker container run (
--init,--stop-timeout) - Docker docs: docker container stop (the 10-second default)
- Dockerfile reference: HEALTHCHECK
- Node.js docs: server.close() and server.keepAliveTimeout
- Node.js docs: --watch