Docker Best Practices in Practice: Build a Smaller, Safer Node.js Image

Docker Best Practices in Practice: Build a Smaller, Safer Node.js Image

Docker is easy to start with: write a Dockerfile, run docker build, and ship the image. The harder part is building images that are small, predictable, secure, and pleasant to run in development and CI/CD. This article walks through a practical Docker setup for a small Node.js API, but the same ideas apply to Python, PHP, Go, or frontend apps.

We will start with a common beginner Dockerfile, improve it step by step, and finish with a production-ready version using multi-stage builds, dependency caching, a non-root user, and a clean .dockerignore.

The Example App

Assume we have a small Express API with this structure:

my-api/ ├── src/ │ └── server.js ├── package.json ├── package-lock.json └── Dockerfile

Here is the example src/server.js:

const express = require("express"); const app = express(); const port = process.env.PORT || 3000; app.get("/health", (req, res) => { res.json({ status: "ok", service: "my-api" }); }); app.get("/users/:id", (req, res) => { res.json({ id: req.params.id, name: "Demo User" }); }); app.listen(port, () => { console.log(`API running on port ${port}`); });

And a simple package.json:

{ "name": "my-api", "version": "1.0.0", "main": "src/server.js", "scripts": { "start": "node src/server.js" }, "dependencies": { "express": "^4.18.3" } }

The Beginner Dockerfile

A beginner Dockerfile often looks like this:

FROM node:20 WORKDIR /app COPY . . RUN npm install EXPOSE 3000 CMD ["npm", "start"]

This works, but it has several problems:

  • COPY . . copies everything, including local logs, test files, node_modules, and secrets if they exist.
  • npm install can produce different dependency versions over time.
  • The app runs as the default root user inside the container.
  • Docker cache is not used efficiently, so small source changes may reinstall all dependencies.
  • The final image includes development files that production does not need.

Let’s fix these one by one.

Use a .dockerignore File

Before improving the Dockerfile, create a .dockerignore file. This works like .gitignore, but for Docker build context. It prevents unnecessary files from being sent to the Docker daemon.

node_modules npm-debug.log Dockerfile .dockerignore .git .gitignore .env coverage dist README.md *.log

This matters more than many developers think. If your project has a large node_modules directory, Docker may spend time copying files that should never be inside the image. Even worse, accidentally copying .env may leak credentials into an image layer.

Improve Dependency Caching

Docker builds images layer by layer. If one layer changes, Docker rebuilds that layer and the layers after it. In the beginner Dockerfile, this line causes a problem:

COPY . .

Every source code change invalidates the cache before npm install. A better approach is to copy dependency files first, install dependencies, and only then copy application code.

FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY src ./src EXPOSE 3000 CMD ["npm", "start"]

This version is already better. When you change src/server.js, Docker can reuse the dependency installation layer as long as package.json and package-lock.json did not change.

Notice the use of npm ci instead of npm install. For applications with a lockfile, npm ci gives a more predictable install because it follows package-lock.json exactly. That is what you want in CI and production builds.

Use a Smaller Base Image

The image node:20 is convenient, but it contains more operating system packages than most APIs need. For many web services, node:20-slim is a good default because it is smaller while still being Debian-based and familiar.

FROM node:20-slim

You may also see Alpine-based images, such as node:20-alpine. They can be smaller, but some packages with native dependencies may behave differently because Alpine uses musl libc instead of glibc. For junior and mid-level teams, slim is often the safer first optimization.

Run as a Non-Root User

Containers are isolated, but they are not magic security boundaries. Running your application as root inside the container is unnecessary for most web apps. The official Node image already includes a node user, so we can use it.

FROM node:20-slim WORKDIR /app COPY package*.json ./ RUN npm ci --omit=dev COPY --chown=node:node src ./src COPY --chown=node:node package*.json ./ USER node EXPOSE 3000 CMD ["npm", "start"]

The --chown=node:node option makes sure the copied files belong to the node user. Then USER node tells Docker to run the container process as that user.

Add a Healthcheck

A healthcheck lets Docker or your orchestration platform know whether the application is actually responding. The container may be running, but the API may still be broken. Add a simple healthcheck using Node itself so you do not need to install curl.

HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))"

Now Docker can check the /health endpoint. This is especially useful when debugging containers locally or running services with Docker Compose.

Use Multi-Stage Builds When You Have a Build Step

For plain JavaScript, a multi-stage build may not be necessary. But if you use TypeScript, bundlers, or frontend build tools, multi-stage builds keep build tools out of the final image.

Here is an example for a TypeScript API:

FROM node:20-slim AS deps WORKDIR /app COPY package*.json ./ RUN npm ci FROM deps AS build COPY tsconfig.json ./ COPY src ./src RUN npm run build FROM node:20-slim AS runtime WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --from=build --chown=node:node /app/dist ./dist USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD ["node", "dist/server.js"]

This Dockerfile has three stages:

  • deps installs all dependencies needed for building.
  • build compiles the app, for example from TypeScript to JavaScript.
  • runtime installs only production dependencies and copies only the compiled output.

The final image does not include tsconfig.json, raw TypeScript files, or development dependencies unless you explicitly copy them.

Build and Run the Image

Build the image with a clear name and tag:

docker build -t my-api:local .

Run it locally:

docker run --rm -p 3000:3000 my-api:local

Test the health endpoint:

curl http://localhost:3000/health

You should get:

{ "status": "ok", "service": "my-api" }

Use Environment Variables Correctly

Do not bake environment-specific values into the image. The same image should run in development, staging, and production. Pass configuration at runtime instead.

docker run --rm \ -p 3000:3000 \ -e PORT=3000 \ -e LOG_LEVEL=info \ my-api:local

For local development, Docker Compose is more convenient:

services: api: build: context: . ports: - "3000:3000" environment: PORT: 3000 LOG_LEVEL: debug

Avoid copying .env into the image. Load it at runtime with Compose or your deployment platform.

Practical Checklist for Better Docker Images

  • Use a .dockerignore file to keep the build context clean.
  • Copy dependency files before source files to improve Docker cache usage.
  • Use npm ci when a lockfile exists.
  • Prefer smaller base images such as node:20-slim when compatible.
  • Run the app as a non-root user.
  • Use multi-stage builds when your project has a compile or bundle step.
  • Keep secrets and environment-specific config out of the image.
  • Add a healthcheck for runtime visibility.
  • Tag images clearly, for example my-api:1.4.2 or with a Git commit SHA in CI.

Final Production Dockerfile

For our simple JavaScript API, this is a solid production-ready Dockerfile:

FROM node:20-slim WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev && npm cache clean --force COPY --chown=node:node src ./src COPY --chown=node:node package*.json ./ USER node EXPOSE 3000 HEALTHCHECK --interval=30s --timeout=3s --start-period=10s --retries=3 \ CMD node -e "fetch('http://localhost:3000/health').then(r => process.exit(r.ok ? 0 : 1)).catch(() => process.exit(1))" CMD ["node", "src/server.js"]

Conclusion

Good Docker practices are mostly about removing surprises. Copy fewer files. Install dependencies predictably. Keep the runtime image small. Do not run as root. Do not bake secrets into images. Add a healthcheck so failures are visible.

For junior and mid-level developers, the biggest improvement is learning to treat the Dockerfile as production code, not as a one-time setup file. A clean Dockerfile makes local development smoother, CI builds faster, and production deployments safer.


Leave a Reply

Your email address will not be published. Required fields are marked *