Ravindra BagaleCourses & study guides मराठी

Chapter 54: Dockerfile Examples for Node.js, Python Flask and Django, Spring Boot, .NET, Go, PHP, nginx, MySQL, Postgres and Redis

54.1 Node.js Dockerfile (Express) for production

Friends, today we write a real production Dockerfile for a Node.js Express API, one line at a time. We start with the simplest file that works, see why it is too big and unsafe, and improve it step by step: a pinned base image, a good layer order, npm ci, a multi-stage build, a non-root user, a healthcheck and clean shutdown.

1. Start from a story

At Netflix's internal tools team, Vikram wrote a small Express API that lists test devices. On his laptop, npm start worked perfectly.

On the build server it failed: a different Node version. A teammate copied node_modules from a Mac, and a native package crashed on Linux.

Vikram's first Docker image was 1.1 GB, ran as root, and contained test files, the .git folder and even his local .env file.

After one afternoon of small fixes, the image was 140 MB, ran as the node user, rebuilt in seconds when only code changed, and shut down cleanly when Docker stopped it. Today we repeat his fixes one by one.

Stage depsnpm ci with lock fileStage buildcompile TypeScript if anyStage runtimenode:20-alpine, prod deps onlyUSER nodeno rootCMD node server.jsPID 1 handles signals
Picture 1: the production pattern

2. A tiny app to containerise

Step 1: The sample API

Why: Something real to build.

What: A three-file Express app: package.json, package-lock.json and server.js with a /health route.

Example 1: package.json

Type this:

printf '{\n  "name": "devices-api",\n  "version": "1.0.0",\n  "main": "server.js",\n  "scripts": {"start": "node server.js"},\n  "dependencies": {"express": "4.21.1"}\n}\n' > package.json && npm install --package-lock-only --silent && ls

You will see:

package-lock.json
package.json

What it means: Lock file pins every version.

Example 2: server.js

Type this:

printf 'const express=require("express");\nconst app=express();\napp.get("/health",(q,r)=>r.json({ok:true}));\napp.get("/devices",(q,r)=>r.json([{id:1,name:"Fire TV"}]));\nconst s=app.listen(process.env.PORT||3000,"0.0.0.0",()=>console.log("listening"));\nprocess.on("SIGTERM",()=>s.close(()=>process.exit(0)));\n' > server.js && node --check server.js && echo ok

You will see:

ok

What it means: Listens on 0.0.0.0 and handles SIGTERM.

Example 3: Run without Docker

Type this:

npm ci --silent && (node server.js &) && sleep 1 && curl -s localhost:3000/health

You will see:

listening
{"ok":true}

What it means: Works on the laptop.

Step 2: The naive Dockerfile

Why: Works, but badly.

What: This first version copies everything and installs everything. It runs, so we can measure how bad it is.

Example 4: Write

Type this:

printf 'FROM node\nCOPY . .\nRUN npm install\nCMD npm start\n' > Dockerfile && docker build -q -t devices:naive .

You will see:

sha256:71c0e9d2a4b8

What it means: It builds.

Example 5: Size

Type this:

docker images devices:naive --format '{{.Size}}'

You will see:

1.12GB

What it means: Huge: the full Debian image plus build tools.

Example 6: Who runs it

Type this:

docker run --rm devices:naive whoami

You will see:

root

What it means: Runs as root: a security risk.

3. Fix it step by step

Step 3: Pin the base image

Why: Same Node everywhere.

What: node without a tag means latest, which changes. Pin the major version and a slim variant.

Example 7: Pinned

Type this:

printf 'FROM node:20.18-alpine\n' > Dockerfile && echo pinned

You will see:

pinned

What it means: Alpine is small; 20.18 is fixed.

Example 8: Compare bases

Type this:

docker images node --format '{{.Tag}} {{.Size}}' | grep -E '^(20.18-alpine|20.18-slim|20.18) '

You will see:

20.18-alpine 133MB
20.18-slim 200MB
20.18 1.1GB

What it means: Alpine is the smallest.

Example 9: Digest pin

Type this:

docker buildx imagetools inspect node:20.18-alpine | grep -m1 Digest

You will see:

Digest:    sha256:c1f0a2b3...

What it means: For the strictest builds, pin the digest too.

Step 4: WORKDIR and .dockerignore

Why: A clean home and a clean context.

What: WORKDIR sets /app. .dockerignore keeps node_modules, .git, .env and tests out of the image.

Example 10: dockerignore

Type this:

printf 'node_modules\n.git\n.env\n*.log\ncoverage\ntest\nDockerfile\n' > .dockerignore && wc -l .dockerignore

You will see:

7 .dockerignore

What it means: Your secrets never enter the build.

Example 11: WORKDIR

Type this:

printf 'FROM node:20.18-alpine\nWORKDIR /app\n' > Dockerfile && echo ok

You will see:

ok

What it means: Creates /app and moves into it.

Example 12: Context size

Type this:

docker build --no-cache -t t . 2>&1 | grep 'transferring context'

You will see:

=> => transferring context: 2.10kB

What it means: Kilobytes instead of megabytes.

Step 5: Copy package files first

Why: Use the layer cache.

What: Copy only package.json and the lock file, install, and then copy the code. Code changes then do not reinstall dependencies.

Example 13: Order

Type this:

printf 'FROM node:20.18-alpine\nWORKDIR /app\nCOPY package.json package-lock.json ./\nRUN npm ci --omit=dev\nCOPY . .\nCMD ["node","server.js"]\n' > Dockerfile && docker build -q -t devices:v2 .

You will see:

sha256:4d1e7c0a9f22

What it means: Dependencies are their own layer.

Example 14: Change code

Type this:

printf '// tweak\n' >> server.js && docker build -t devices:v2 . 2>&1 | grep -E 'CACHED|npm ci'

You will see:

=> CACHED [3/5] COPY package.json package-lock.json ./
=> CACHED [4/5] RUN npm ci --omit=dev

What it means: npm ci was skipped.

Example 15: Time

Type this:

time docker build -q -t devices:v2 . >/dev/null

You will see:

real    0m1.4s

What it means: Rebuild in about a second.

Step 6: npm ci instead of npm install

Why: Exact, repeatable installs.

What: npm ci installs exactly what the lock file says and fails if package.json and the lock disagree. --omit=dev skips test tools.

Example 16: Mismatch

Type this:

npm pkg set dependencies.lodash=4.17.21 && docker build -q -t t . 2>&1 | grep -m1 'npm ci'

You will see:

npm ci can only install packages when your package.json and package-lock.json are in sync

What it means: Fails early instead of drifting.

Example 17: Fix

Type this:

npm install --package-lock-only --silent && docker build -q -t t . && echo fixed

You will see:

sha256:9a0b...
fixed

What it means: Lock updated.

Example 18: Cache mount

Type this:

printf 'RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev\n'

You will see:

RUN --mount=type=cache,target=/root/.npm npm ci --omit=dev

What it means: BuildKit keeps the npm cache between builds.

Step 7: Exec-form CMD and signals

Why: Stop quickly and cleanly.

What: CMD ["node","server.js"] makes node PID 1, so it receives SIGTERM. npm start or shell form adds a middle process that may not pass the signal on.

Example 19: Shell form

Type this:

printf 'CMD npm start\n' && docker run -d --name a devices:naive && time docker stop a

You will see:

CMD npm start
real    0m10.3s

What it means: Docker waited 10 seconds, then killed it.

Example 20: Exec form

Type this:

docker run -d --name b devices:v2 && time docker stop b

You will see:

real    0m0.4s

What it means: node got SIGTERM and closed at once.

Example 21: init

Type this:

docker run -d --init --name c devices:v2 && docker exec c ps -o pid,comm | head -3

You will see:

PID   COMMAND
    1 docker-init
    7 node

What it means: --init adds a tiny init that reaps zombie processes.

Step 8: Run as the node user

Why: Never root in production.

What: The official image already has a user called node with UID 1000. Switch to it with USER and give it ownership of the app files.

Example 22: USER

Type this:

printf 'COPY --chown=node:node . .\nUSER node\n' && echo 'add before CMD'

You will see:

COPY --chown=node:node . .
USER node
add before CMD

What it means: Files owned by node.

Example 23: Check

Type this:

docker build -q -t devices:v3 . && docker run --rm devices:v3 id

You will see:

uid=1000(node) gid=1000(node) groups=1000(node)

What it means: Not root.

Example 24: Write test

Type this:

docker run --rm devices:v3 sh -c 'touch /etc/x' 2>&1

You will see:

touch: /etc/x: Permission denied

What it means: An attacker cannot change system files.

Step 9: Multi-stage build

Why: Build tools stay behind.

What: One stage installs and builds, the final stage copies only what runs. This matters most for TypeScript or native modules.

Example 25: Dockerfile

Type this:

printf 'FROM node:20.18-alpine AS deps\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci\n\nFROM deps AS build\nCOPY . .\nRUN npm run build --if-present\n\nFROM node:20.18-alpine AS runtime\nENV NODE_ENV=production\nWORKDIR /app\nCOPY package*.json ./\nRUN npm ci --omit=dev \x26\x26 npm cache clean --force\nCOPY --from=build --chown=node:node /app .\nUSER node\nEXPOSE 3000\nCMD ["node","server.js"]\n' > Dockerfile && grep -c FROM Dockerfile

You will see:

3

What it means: deps, build, runtime.

Example 26: Build

Type this:

docker build -q -t devices:1.0.0 . && docker images devices:1.0.0 --format '{{.Size}}'

You will see:

141MB

What it means: From 1.12 GB to 141 MB.

Example 27: History

Type this:

docker history devices:1.0.0 --format '{{.Size}} {{.CreatedBy}}' | head -3

You will see:

0B CMD ["node" "server.js"]
0B USER node
2.1MB COPY /app . # buildkit

What it means: Only the runtime layers ship.

Step 10: ENV, EXPOSE and HEALTHCHECK

Why: Document and self-check.

What: NODE_ENV=production makes Express faster. EXPOSE documents the port. HEALTHCHECK lets Docker see if the app is alive.

Example 28: Healthcheck

Type this:

printf 'HEALTHCHECK --interval=15s --timeout=3s --retries=3 CMD wget -qO- http://127.0.0.1:3000/health || exit 1\n' >> Dockerfile && echo added

You will see:

added

What it means: Alpine has wget built in.

Example 29: Run

Type this:

docker build -q -t devices:1.0.1 . && docker run -d -p 3000:3000 --name d devices:1.0.1 && sleep 20 && docker ps --format '{{.Names}} {{.Status}}'

You will see:

d Up 20 seconds (healthy)

What it means: Healthy status.

Example 30: Env check

Type this:

docker exec d node -e 'console.log(process.env.NODE_ENV)'

You will see:

production

What it means: Set in the image.

Step 11: Run it with Compose

Why: The image in a real stack.

What: Put the image in compose.yaml with a port, a restart policy and a memory limit, and check it from outside.

Example 31: Compose

Type this:

printf 'services:\n  api:\n    image: devices:1.0.1\n    ports: ["3000:3000"]\n    restart: unless-stopped\n    deploy: {resources: {limits: {memory: 256M}}}\n' > compose.yaml && docker compose up -d --wait

You will see:

✔ Container devices-api-1  Healthy

What it means: --wait uses the image's HEALTHCHECK.

Example 32: Call it

Type this:

curl -s localhost:3000/devices

You will see:

[{"id":1,"name":"Fire TV"}]

What it means: Served from the container.

Example 33: Memory

Type this:

docker compose stats --no-stream --format '{{.MemUsage}}'

You will see:

38.2MiB / 256MiB

What it means: Plenty of headroom.

Step 12: Scan and label

Why: Know what you ship.

What: Scan the image for known vulnerabilities and add OCI labels for version and source.

Example 34: Scan

Type this:

docker scout cves devices:1.0.1 --only-severity critical,high | tail -2

You will see:

0C     0H     2M     1L

What it means: No critical or high issues.

Example 35: Labels

Type this:

printf 'LABEL org.opencontainers.image.version="1.0.1" org.opencontainers.image.title="devices-api"\n' >> Dockerfile && echo ok

You will see:

ok

What it means: Metadata others can read.

Example 36: Read labels

Type this:

docker build -q -t devices:1.0.1 . && docker inspect devices:1.0.1 --format '{{index .Config.Labels "org.opencontainers.image.version"}}'

You will see:

1.0.1

What it means: Version inside the image.

4. Common mistakes and how Vikram avoids them

Mistake one: FROM node with no tag. Vikram pins node:20.18-alpine.

Mistake two: COPY . . before npm ci. Every code change reinstalls everything. He copies package files first.

Mistake three: no .dockerignore. His .env and node_modules ended up in the image. Now they are ignored.

Mistake four: CMD npm start. Stops took 10 seconds. He uses exec-form node.

Mistake five: running as root. He adds USER node.

Check yourself

Q1. Why copy package.json before the code?

So the npm ci layer stays cached when only code changes.

Q2. npm ci or npm install in Docker?

npm ci: exact versions from the lock file.

Q3. Why exec-form CMD?

node becomes PID 1 and receives SIGTERM directly.

Q4. Which user does the official Node image provide?

node, UID 1000.

Friends, pin the base, ignore junk, copy package files first, npm ci, multi-stage, USER node, exec CMD and a healthcheck. Did you understand? Let us try it once more. Take your own Express app and bring its image under 150 MB.

Ravindra Bagale's Tip

Always run docker history on a new image. If you see a big layer you did not expect, it is usually a missing .dockerignore line.

Lab: try at home

  1. Build the naive image and note its size and user.
  2. Pin the base and add .dockerignore.
  3. Reorder COPY and prove npm ci is cached.
  4. Switch to exec-form CMD and time docker stop.
  5. Add USER node and a healthcheck.
  6. Make it multi-stage and compare sizes.

Recap

  • Pin node:20-alpine, never plain node.
  • Copy package files, npm ci, then code.
  • .dockerignore keeps secrets and node_modules out.
  • Exec-form CMD handles SIGTERM.
  • USER node removes root.
  • Multi-stage plus healthcheck gives a small, safe image.

Ravi Sir's Cards for this lesson

मराठी cards