Home → Running Node.js in Docker: one-off scripts, --watch for development, and shutting down properly

Running Node.js in Docker: one-off scripts, --watch for development, and shutting down properly

By · Node.js & JavaScript developer
Published March 11, 2023 · Updated October 1, 2026

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/arm64

The 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/arm64

npm 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 :3000

Editing 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 commandCtrl+C (SIGINT)docker stopExit code
node server.mjsignored, keeps running10.2 s, then SIGKILL137
--init + node server.mjsexitsinstant130 / 143
node graceful.mjs (own handlers)graceful exitinstant, graceful0
npm startignored, keeps running0.7 s, with an npm error … signal SIGTERM dump1

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 (or init: true in 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 start as the container command. npm sits between Docker and Node: Ctrl+C was ignored entirely, and docker stop ended in an npm error and exit code 1. Put CMD ["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 1

The 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 0

The 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*.json and running npm ci before copying the source means code changes don't reinstall dependencies.
  • USER node: the official images include an unprivileged node user, and whoami confirms the app isn't running as root.
  • The healthcheck uses Node's built-in fetch, because slim images don't include curl or wget. Docker marks the container (healthy) once it passes.
  • --init at run time: PID 1 is docker-init and Node runs as PID 7. Combined with the signal handlers, docker stop took 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

About Code with Node.js

This is a personal blog and reference point of a Node.js developer.

I write and explain how different Node and JavaScript aspects work, as well as research popular and cool packages, and of course fail time to time.