Skip to content
Laviyx Learn

Try

Docker and Kubernetes course menu

How to Dockerize a Node.js and Express App

Write a good Dockerfile for a Node.js app: an Alpine base image, npm ci, production dependencies only, the built-in node user, and handling shutdown signals.

Beginner6 min readUpdated
On this page

Now we package an Express app.

The app#

Terminal
mkdir node-app
cd node-app
cat > package.json <<'EOF'
{
  "name": "node-demo",
  "version": "1.0.0",
  "type": "module",
  "scripts": { "start": "node server.js" },
  "dependencies": { "express": "^5.0.0" }
}
EOF
cat > server.js <<'EOF'
import express from "express";

const app = express();
const port = process.env.PORT ?? 3000;

app.get("/", (req, res) => res.json({ message: "Hello from Express" }));
app.get("/health", (req, res) => res.json({ status: "ok" }));

const server = app.listen(port, () => console.log(`Listening on ${port}`));

process.on("SIGTERM", () => {
  console.log("Shutting down");
  server.close(() => process.exit(0));
});
EOF
cat > .dockerignore <<'EOF'
node_modules
npm-debug.log
.env
.git
EOF
docker run --rm -v "$PWD":/app -w /app node:24-alpine npm install --package-lock-only --silent 2> /dev/null
ls
Output
package-lock.json
package.json
server.js

We used a container to create package-lock.json, so Node.js did not need to be installed. You should commit that file.

The Dockerfile#

Terminal
cat > Dockerfile <<'EOF'
FROM node:24-alpine

ENV NODE_ENV=production
WORKDIR /app

COPY package*.json ./
RUN npm ci --omit=dev

COPY . .

USER node
EXPOSE 3000
CMD ["node", "server.js"]
EOF
  • npm ci installs the exact versions from the lock file, and it is the right command for builds.
  • --omit=dev leaves out development tools.
  • node_modules is in .dockerignore, so that packages from your computer never overwrite those installed for Linux.
  • The official image includes a user called node.
  • CMD ["node", "server.js"] runs Node directly.

Build and run#

Terminal
docker build -q -t node-demo:1.0 . > /dev/null
docker run -d --name api -p 3000:3000 node-demo:1.0 > /dev/null
sleep 2
curl -s http://localhost:3000/
echo
docker exec api whoami
docker images node-demo --format '{{.Size}}'
Output
{"message":"Hello from Express"}
node
177MB

Clean shutdown#

docker stop sends SIGTERM. Our code catches it, finishes current requests, and exits:

Terminal
time docker stop api > /dev/null
docker logs api
docker rm api > /dev/null
Output

real	0m0.149s
user	0m0.006s
sys	0m0.009s
Listening on 3000
Shutting down

It stopped almost at once.

If a program does not handle signals, docker run --init adds a small helper process to pass them on.

Common mistakes#

Practice#

Practice exercise

Run the image on host port 3100 with PORT=4000 set inside the container, and call /health.

Show hint

The container now listens on 4000, so use -p 3100:4000.

Show solution
Terminal
docker run -d --name p1 -p 3100:4000 -e PORT=4000 node-demo:1.0 > /dev/null
sleep 2
curl -s http://localhost:3100/health
echo
docker rm -f p1 > /dev/null
Output
{"status":"ok"}

Key takeaway#

Next: Multi-Stage Builds.

Finished this lesson?

Some links are affiliate links. If you buy through them, Laviyx Learn may earn a commission at no extra cost to you. As an Amazon Associate, we earn from qualifying purchases.