A Docker image is an immutable template made of layers: the OS, the runtime, dependencies and the app code. A container is an isolated process started from an image, with its own writable layer. Images are described in a Dockerfile; for Node.js a good Dockerfile is multi-stage, with .dockerignore, a non-privileged user and HEALTHCHECK, and data lives in volumes, not inside the container.
After this you will be able to
- explain the difference between an image, a container, a layer, a registry and a volume
- write a multi-stage Dockerfile for a Node.js app with
.dockerignore,USER nodeandHEALTHCHECK, ordering instructions for the build cache - run a container with a port, environment variables and a volume and inspect it with
docker ps,logsandexec - explain why
docker stopcan wait 10 seconds and fix it withSIGTERMhandling or--init
Know beforehand
Verified with
Docker Engine 29.6 (Docker Desktop, WSL 2)BuildKit, dockerfile:1Node.js 24.21 (node:24.21.0-alpine, Alpine 3.24)Express 5.3.0TypeScript 7.0.2
Checked against docs: October 11, 2026
Contents
- Image, container, registry: what Docker does
- The app we are going to package
- A first Dockerfile and what is wrong with it
- A proper Dockerfile: stages, .dockerignore, USER, HEALTHCHECK
- Layers and the build cache
- Running: run, ps, logs, exec, stop
- Ports, environment variables and volumes
- Common mistakes and exercises
Image, container, registry: what Docker does
The “works on my machine” problem comes from different environments: another Node.js version, another OS, a forgotten system library. Docker packages the app with everything it needs and runs it the same way on a laptop, in CI and on a server. The Docker documentation defines a container as an isolated process: unlike a virtual machine it has no kernel of its own — all containers on a machine share the host kernel, so they start in a fraction of a second.
Image
- What it is
- An immutable set of layers: files, libraries, runtime, code and the start command
- Analogy
- An installation disc
Container
- What it is
- A process started from an image plus a thin writable layer on top of the image layers
- Analogy
- An installed, running program
Dockerfile
- What it is
- A text recipe for building an image, one instruction per layer
- Analogy
- The disc's source code
Registry
- What it is
- Image storage; Docker Hub by default
- Analogy
- A disc shop
Volume
- What it is
- Data storage that outlives the container
- Analogy
- An external drive
A layer is a set of filesystem changes: added, modified and deleted files. Once created, a layer never changes, and identical layers are shared between images: node:24.21.0-alpine is downloaded once no matter how many apps use it. Try running Node.js without installing it:
$ docker run --rm node:24.21.0-alpine node -e "console.log(process.version, process.platform, process.arch)"
v24.21.0 linux x64
$ docker image ls node:24.21.0-alpine
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
node:24.21.0-alpine ebfe2f904627 242MB 62MB --rm removes the container after exit.The lesson assumes Docker is installed (Docker Desktop on Windows and macOS, Docker Engine on Linux) and that you know a Node.js project from “Node.js from scratch”. Commands were run in Git Bash; in PowerShell they are the same except time and sleep.
The app we are going to package
A small notes API on Express 5 and TypeScript: GET /health for liveness, GET and POST /api/notes, notes are saved to a JSON file in the DATA_DIR folder. TypeScript is only needed at build time — exactly the reason for a multi-stage Dockerfile. The SIGTERM handler lets docker stop shut the app down cleanly.
{
"name": "notes-api",
"version": "1.0.0",
"private": true,
"type": "module",
"scripts": {
"build": "tsc",
"start": "node dist/server.js"
},
"dependencies": {
"express": "^5.3.0"
},
"devDependencies": {
"@types/express": "^5.0.6",
"@types/node": "^24.19.2",
"typescript": "^7.0.2"
}
}{
"compilerOptions": {
"target": "es2024",
"module": "nodenext",
"rootDir": "src",
"outDir": "dist",
"strict": true,
"skipLibCheck": true
},
"include": ["src"]
}src → dist.import express from "express";
import { mkdir, readFile, writeFile } from "node:fs/promises";
import { join } from "node:path";
type Note = { id: number; text: string; createdAt: string };
const port = Number(process.env.PORT ?? 3000);
const dataDir = process.env.DATA_DIR ?? "./data";
const file = join(dataDir, "notes.json");
async function loadNotes(): Promise<Note[]> {
try {
return JSON.parse(await readFile(file, "utf8")) as Note[];
} catch {
return [];
}
}
const app = express();
app.use(express.json());
app.get("/health", (_req, res) => {
res.json({ status: "ok", uptime: Math.round(process.uptime()) });
});
app.get("/api/notes", async (_req, res) => {
res.json(await loadNotes());
});
app.post("/api/notes", async (req, res) => {
const text = typeof req.body?.text === "string" ? req.body.text.trim() : "";
if (!text) {
res.status(400).json({ error: "text is required" });
return;
}
const notes = await loadNotes();
const note: Note = { id: notes.length + 1, text, createdAt: new Date().toISOString() };
notes.push(note);
await mkdir(dataDir, { recursive: true });
await writeFile(file, JSON.stringify(notes, null, 2));
res.status(201).json(note);
});
const server = app.listen(port, () => {
console.log(`notes-api listening on port ${port} (NODE_ENV=${process.env.NODE_ENV ?? "development"})`);
});
// docker stop sends SIGTERM: finish open requests and exit instead of waiting to be killed
process.on("SIGTERM", () => {
console.log("SIGTERM received, shutting down");
server.close(() => process.exit(0));
});tsc with no errors, runs on Node.js 24.21 and 24.18.npm install express@5.3.0
npm install -D typescript@7.0.2 @types/express@5.0.6 @types/node@24.19.2
npx tsc && node dist/server.jspackage-lock.json is needed for npm ci in the image.A first Dockerfile and what is wrong with it
Let's start with the Dockerfile people write the first time. It works, and it shows the typical mistakes well.
FROM node:24.21.0-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]$ docker build -t notes-api:naive -f Dockerfile.naive .
…
#6 [internal] load build context
#6 transferring context: 36.09MB 3.1s done
#7 [3/5] COPY . .
#8 [4/5] RUN npm install
#8 1.800 added 1 package, removed 1 package, and audited 79 packages in 2s
#9 [5/5] RUN npm run build
#10 naming to docker.io/library/notes-api:naive donedocker build --progress=plain output (Docker 29.6, BuildKit).$ docker run -d --name notes-naive -p 127.0.0.1:8081:3000 notes-api:naive
a372e6afa938bdfd8a9c6ce1d2e2db88ef4e55460403ea4ae7e78902308e5d6d
$ sleep 2; docker exec notes-naive ps -o pid,user,args
PID USER COMMAND
1 root npm start
18 root {MainThread} node dist/server.js
25 root ps -o pid,user,args
$ time docker stop notes-naive
notes-naive
real 0m0.528s
user 0m0.060s
sys 0m0.077s
$ docker rm notes-naive
notes-naive
$ docker image ls notes-api
IMAGE ID DISK USAGE CONTENT SIZE EXTRA
notes-api:1.0 98bb9f6c383d 247MB 62.3MB
notes-api:naive fcc71eb114bb 354MB 93MB COPY . .without.dockerignoresent 36 MB to the builder — mostlynode_modulesfrom a Windows machine; npm had to replace the package with the TypeScript compiler built for Windows. A.envwith secrets would have ended up in the image the same way- Any code change invalidates the
COPY . .layer cache, andnpm installruns again on every build - TypeScript and type packages stayed in the image — build tools not needed at runtime: 354 MB vs 247 MB
- Processes run as root; PID 1 is
npm, not the app itself
A proper Dockerfile: stages, .dockerignore, USER, HEALTHCHECK
First, .dockerignore — the list of what does not go into the build context; the syntax is similar to .gitignore. Dependencies and dist are built inside the image, and secrets have no place there.
# Do not send to the builder: dependencies and build output are created inside the image
node_modules
dist
data
# Secrets and local files never belong in an image
.env
.env.*
.git
npm-debug.log*
Dockerfile*
.dockerignore.gitignore-style patterns.Now the multi-stage build: each FROM starts a new stage, and COPY --from takes only what is needed from a previous one. The final image gets Node.js, production dependencies and compiled code — no TypeScript and no sources.
# syntax=docker/dockerfile:1
ARG NODE_VERSION=24.21.0
# 1. Build: all dependencies, including TypeScript, compile src -> dist
FROM node:${NODE_VERSION}-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY tsconfig.json ./
COPY src ./src
RUN npm run build
# 2. Production dependencies only, without devDependencies
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# 3. Final image: Node.js, production node_modules and compiled code
FROM node:${NODE_VERSION}-alpine AS runtime
ENV NODE_ENV=production \
DATA_DIR=/app/data
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
COPY package.json ./
RUN mkdir -p /app/data && chown node:node /app/data
USER node
EXPOSE 3000
HEALTHCHECK --interval=10s --timeout=3s --start-period=5s --retries=3 \
CMD ["node", "-e", "fetch('http://127.0.0.1:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"]
CMD ["node", "dist/server.js"]healthy.ARG NODE_VERSION before FROM
- Why it is here
- The Node.js version in one place, pinned exactly: a build a month later gets the same runtime
FROM … AS build / deps / runtime
- Why it is here
- Three stages: build, production dependencies, final image;
--target buildbuilds only the first
COPY package.json package-lock.json before COPY src
- Why it is here
- The
npm cilayer is rebuilt only when dependencies change, not on every code edit
npm ci --omit=dev
- Why it is here
- An exact install from
package-lock.jsonwithout devDependencies
ENV NODE_ENV=production
- Why it is here
- The variable persists in the image and is visible to the app
USER node
- Why it is here
- The
nodeuser (uid 1000) already exists in the official image; the process runs without root privileges
EXPOSE 3000
- Why it is here
- Documentation only:
-popens the port at run time
HEALTHCHECK
- Why it is here
- Docker calls
/healthevery 10 seconds; exit code 0 meanshealthy, 1 a failure, 3 failures in a row —unhealthy
CMD ["node", …]
- Why it is here
- Exec form: PID 1 is Node.js itself and signals reach the app; not
npm start
$ docker build -t notes-api:1.0 .
…
#9 [build 3/7] COPY package.json package-lock.json ./
#10 [build 4/7] RUN npm ci
#11 [deps 4/4] RUN npm ci --omit=dev
#11 0.960 added 65 packages, and audited 66 packages in 693ms
#10 1.585 added 78 packages, and audited 79 packages in 1s
#14 [build 6/7] COPY src ./src
#15 [build 7/7] RUN npm run build
#15 0.261 > notes-api@1.0.0 build
#15 0.261 > tsc
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#18 [runtime 6/6] RUN mkdir -p /app/data && chown node:node /app/data
#19 naming to docker.io/library/notes-api:1.0 donebuild and deps stages run in parallel.Layers and the build cache
Every instruction that changes files produces a layer. On a rebuild Docker checks whether the instruction and the files it copies changed: if not, the layer comes from the cache (CACHED). Once a layer changes, all layers after it in that stage are rebuilt. That is why rarely changing things (dependencies) are copied earlier and frequently changing ones (code) later. Change one line in src/server.ts and rebuild:
$ docker build -t notes-api:1.1 .
…
#10 [build 3/7] COPY package.json package-lock.json ./
#10 CACHED
#9 [build 4/7] RUN npm ci
#9 CACHED
#12 [build 6/7] COPY src ./src
#12 DONE 0.0s
#13 [build 7/7] RUN npm run build
#13 DONE 0.3s
#14 [deps 4/4] RUN npm ci --omit=dev
#14 CACHED
#15 [runtime 3/6] COPY --from=deps /app/node_modules ./node_modules
#15 CACHED
#16 [runtime 4/6] COPY --from=build /app/dist ./dist
#16 DONE 0.0snpm ci in both stages comes from the cache; only the code and the dist layer were rebuilt.$ docker history notes-api:1.0 --format 'table {{.CreatedBy}}\t{{.Size}}' | head -9
CREATED BY SIZE
CMD ["node" "dist/server.js"] 0B
HEALTHCHECK {Test:[CMD node -e fetch('http:/… 0B
EXPOSE [3000/tcp] 0B
USER node 0B
RUN /bin/sh -c mkdir -p /app/data && chown n… 12.3kB
COPY package.json ./ # buildkit 12.3kB
COPY /app/dist ./dist # buildkit 16.4kB
COPY /app/node_modules ./node_modules # buil… 4.44MBnode:24.21.0-alpine.This shows that of the 247 MB image only about 4.5 MB are our own layers; the rest is the base image, already downloaded on the server and shared by every app on it. The documentation recommends rebuilding images regularly (docker build --pull) — an image is a snapshot, and security updates of the base image get into it only on a rebuild.
Running: run, ps, logs, exec, stop
Start the container in the background (-d) with a name and a port, and walk through the main commands.
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
14431bf467dc7af7235e5ea4abf38c7864f7775543f5499901030e23ae2c6f06
$ docker ps --filter name=notes
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
14431bf467dc notes-api:1.0 "docker-entrypoint.s…" Less than a second ago Up Less than a second (health: starting) 127.0.0.1:8080->3000/tcp notes
$ curl -s http://127.0.0.1:8080/health
{"status":"ok","uptime":0}$ sleep 8; docker ps --filter name=notes
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
14431bf467dc notes-api:1.0 "docker-entrypoint.s…" 9 seconds ago Up 8 seconds (healthy) 127.0.0.1:8080->3000/tcp notes
$ docker logs notes
notes-api listening on port 3000 (NODE_ENV=production)
$ curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Buy coffee filters"}'
{"id":1,"text":"Buy coffee filters","createdAt":"2026-10-12T01:58:22.446Z"}$ docker exec notes whoami
node
$ docker exec notes ls -la /app
total 28
drwxr-xr-x 1 root root 4096 Oct 12 01:57 .
drwxr-xr-x 1 root root 4096 Oct 12 01:58 ..
drwxr-xr-x 1 node node 4096 Oct 12 01:58 data
drwxr-xr-x 2 root root 4096 Oct 12 01:57 dist
drwxr-xr-x 67 root root 4096 Oct 12 01:57 node_modules
-rwxr-xr-x 1 root root 329 Oct 12 01:56 package.json
$ docker exec notes cat /app/data/notes.json
[
{
"id": 1,
"text": "Buy coffee filters",
"createdAt": "2026-10-12T01:58:22.446Z"
}
]$ docker exec notes ps -o pid,user,args
PID USER COMMAND
1 node {MainThread} node dist/server.js
49 node ps -o pid,user,args
$ docker inspect --format '{{.State.Health.Status}} {{len .State.Health.Log}}' notes
healthy 1
$ time docker stop notes
notes
real 0m0.487s
user 0m0.046s
sys 0m0.091s
$ docker logs notes --tail 2
notes-api listening on port 3000 (NODE_ENV=production)
SIGTERM received, shutting down
$ docker ps -a --filter name=notes
CONTAINER ID IMAGE COMMAND CREATED STATUS PORTS NAMES
14431bf467dc notes-api:1.0 "docker-entrypoint.s…" 11 seconds ago Exited (0) Less than a second ago notes
$ docker rm notes
noteskb- prefix; it is removed in the article.docker run -d --name notes -p 127.0.0.1:8080:3000 image
- What it does
- Create and start a container in the background
docker ps, docker ps -a
- What it does
- Running containers; with
-astopped ones too
docker logs notes, docker logs -f notes
- What it does
- App output;
-ffollows in real time
docker exec notes command, docker exec -it notes sh
- What it does
- Run a command inside a running container;
-itfor an interactive shell
docker stop notes
- What it does
SIGTERM, thenSIGKILLafter 10 seconds
docker rm notes, docker rm -f notes
- What it does
- Remove a stopped container;
-fstops and removes
Why stopping took half a second. docker stop sends SIGTERM to the main process and waits 10 seconds, then kills it with SIGKILL. A process with PID 1 is special on Linux: signals without a handler have no effect on it. The Node.js image documentation states plainly that Node.js was not designed to run as PID 1. Here is what happens without a handler:
$ docker run -d --name pid1 node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
7e0e9b2a5a1331b23b772ba1db38099350be54a98ca08d411a421b600561039b
$ time docker stop pid1
pid1
real 0m10.399s
user 0m0.061s
sys 0m0.091s
$ docker ps -a --filter name=pid1 --format '{{.Names}}: {{.Status}}'
pid1: Exited (137) Less than a second ago
$ docker rm pid1
pid1
$ docker run -d --init --name pid1-init node:24.21.0-alpine node -e "setInterval(() => {}, 1000)"
b334728e597d7e4eabd09cf15e46edc9b3a18b182c21673db9f04a491b9a5ce3
$ time docker stop pid1-init
pid1-init
real 0m0.339s
user 0m0.046s
sys 0m0.091s
$ docker rm pid1-init
pid1-initSIGKILL); with --init — instant.Two fixes: handle SIGTERM in the app, as our server.ts does, or start the container with --init — then PID 1 is a tiny init process that forwards signals to the app. The handler is better: the app gets to close connections and finish writing data.
Ports, environment variables and volumes
A container has its own network: the app listens on port 3000 inside the container, and -p HOST:CONTAINER forwards a host port to it. Environment variables are passed with -e or --env-file. Files written inside a container disappear with it — data needs a volume.
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
25d39d3a88a744f5c87357a3f681ab44b8e0a6544b893d30c0fcca8fa6a2370d
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:24.178Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 notes-api:1.0
f8605e984fe84d46c12abae2c5663e3f0eba212076956b73d1baa01a9d353f73
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[]
$ docker rm -f notes
notes
$ docker volume create notes-data
notes-data
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
05589d4fff5d56e64bfe16409e085c0bb4b5164a84d0d8f621a751f45613db24
$ sleep 1; curl -s -X POST http://127.0.0.1:8080/api/notes -H "Content-Type: application/json" -d '{"text":"Call the supplier"}'
{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}
$ docker rm -f notes
notes
$ docker run -d --name notes -p 127.0.0.1:8080:3000 -v notes-data:/app/data notes-api:1.0
523718889c0c184fd4e10b90cff736bedae3ad6befec681f5f986c0080a087ad
$ sleep 1; curl -s http://127.0.0.1:8080/api/notes
[{"id":1,"text":"Call the supplier","createdAt":"2026-10-12T01:59:28.587Z"}]
$ docker volume ls --filter name=notes-data
DRIVER VOLUME NAME
local notes-data
$ docker volume inspect notes-data --format '{{.Name}} {{.Driver}} {{.Mountpoint}}'
notes-data local /var/lib/docker/volumes/notes-data/_data
$ docker run -d --name notes-2 -p 127.0.0.1:8080:3000 notes-api:1.0
ee5dd4dd6bd165af172b5f0c9d1716414492a49852293f4cfc789585f0665489
docker: Error response from daemon: failed to set up container networking: driver failed programming external connectivity on endpoint notes-2 (9e06e8e275da8ac1e377b36926f60ee4c9c68cd70b86c024a7d380d7c640f326): Bind for 127.0.0.1:8080 failed: port is already allocated
Run 'docker run --help' for more information
$ docker rm notes-2
notes-2
$ docker run -d --name notes-public -p 8090:3000 -e NODE_ENV=staging notes-api:1.0
c73d3880d9022ed756df223c9a44be3f1a1ded52075cd780c75ac2ba77687a11
$ docker ps --filter name=notes --format 'table {{.Names}}\t{{.Ports}}'
NAMES PORTS
notes-public 0.0.0.0:8090->3000/tcp, [::]:8090->3000/tcp
notes 127.0.0.1:8080->3000/tcp
$ sleep 1; docker logs notes-public
notes-api listening on port 3000 (NODE_ENV=staging)
$ docker rm -f notes notes-public
notes
notes-publicnotes-data volume it survived re-creation.-p 8090:3000without an address publishes the port on all interfaces (0.0.0.0and[::]). The Docker documentation explicitly calls port publishing insecure by default: on a server bind to127.0.0.1and expose the app through a reverse proxy- One host port — one container: the second attempt to take
127.0.0.1:8080gotport is already allocated; the created container stayed inCreatedstate and had to be removed - A named volume survives container removal;
docker run --rmremoves only anonymous volumes - An empty volume is populated from the image folder on first mount, owner included. That is why the Dockerfile creates
/app/dataand gives it tonode— otherwise the non-root app cannot write - For development, a host folder is mounted instead of a volume (bind mount,
-v $(pwd)/src:/app/src): convenient, but tied to the machine's filesystem
Common mistakes and exercises
port is already allocated
- Cause
- The host port is taken by another container or program
- What to do
- Use another host port or stop whoever holds it; check
docker ps
curl: (52) Empty reply or no answer while the container runs
- Cause
- The app listens on
127.0.0.1inside the container - What to do
- Listen on
0.0.0.0inside the container (Express does this by default)
EACCES: permission denied on write
- Cause
- The folder in the image or volume belongs to root while the process runs as
node - What to do
- Create the folder and
chownit in the Dockerfile beforeUSER
Every build reinstalls dependencies
- Cause
- Code is copied before
npm ci - What to do
- First
package.json,package-lock.jsonandnpm ci, then the code
docker stop waits 10 seconds
- Cause
- PID 1 does not handle
SIGTERM - What to do
- A signal handler,
--init, exec-formCMD
Data lost after an update
- Cause
- It was written to the container layer
- What to do
- A named volume for everything that must outlive the container
Next step — Docker Compose: the same image, but together with PostgreSQL and Redis, a network, volumes and health checks in one file and one command.
How this was verified12 · passedchecked 11 October 2026
“Run” means the code was executed in a scratch project or container and the output in the article comes from that run. “Partial” means it was checked against stubs instead of external services. “Not run” means it needs a device, an account or a key; the steps were checked against the official documentation. The verification project files and run logs are kept in the InterLAB archive.