Writing a Clean Dockerfile for Node.js Apps

I show how Docker layer caching, the COPY package.json trick, multi-stage builds, and a non-root user turn a messy Dockerfile into a lean, production-safe image.

Writing a Clean Dockerfile for Node.js Apps

Most Node.js Dockerfiles get the job done — but at a cost. The image is over a gigabyte, it rebuilds every dependency from scratch whenever you touch a single line of source code, and it runs as root. On a laptop, none of that causes visible problems. On a busy CI pipeline or in a production container registry, it adds up quickly: slow builds, bloated storage, and containers with more privileges than they need.

The problems are not hard to fix. Docker builds images as a series of cached layers, and the order of your instructions determines how aggressively those layers get reused. Get the order wrong and you’re reinstalling hundreds of packages on every push. Get it right and the install step only re-runs when your package.json changes.

I’m going to walk through the techniques that make the biggest difference — layer caching, multi-stage builds, non-root users, and a proper .dockerignore — and show the before and after side by side.

Quick answer: Copy package.json and the lockfile first, then run npm ci, then copy the rest of your source. Use a multi-stage build so dev dependencies never reach the production image. Create a non-root user and switch to it before CMD. Add a .dockerignore that excludes node_modules, .git, and .env. Together these changes cut image size significantly and keep every build fast.

Series: Part 2 of the Docker series.

On this page

Why every line is a layer — and why order matters

When Docker builds an image, each instruction — FROM, RUN, COPY, ENV — creates a new read-only layer on top of the previous one. Layers are content-addressed: Docker hashes both the instruction text and any files it touches. If the hash matches a previous build, the layer is reused from cache. If it doesn’t, that layer and every layer after it gets rebuilt from scratch.

This is the detail that determines build speed. The rule it implies: put the things that change least at the top, and the things that change most near the bottom.

Here’s the pattern that trips most people up. A Dockerfile that starts with COPY . . puts your entire source tree into a layer early in the build. Every time any file changes — a comment, a test, a README — that COPY instruction fails its cache check, and the npm install that follows rebuilds from scratch. It re-downloads hundreds of packages even though your dependencies didn’t change.

# Any source file change invalidates the install layer
FROM node:20-alpine
WORKDIR /app
COPY . .                # cache busted by any file change
RUN npm install         # rebuilds every time, even for a comment change
EXPOSE 3000
CMD node server.js

The fix is straightforward: separate the dependency files from the rest of your source code.

Copy package.json first to protect the dependency cache

Your package.json and lockfile describe your dependencies. The rest of your source code has no effect on what npm install does. So copy only those two files first, run the install, then copy everything else.

FROM node:20-alpine
WORKDIR /app

# Only these two files affect the install layer
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts

# Source changes no longer bust the install cache
COPY . .
EXPOSE 3000
CMD ["node", "server.js"]

npm ci installs exactly what’s in the lockfile without modifying it. It’s faster and stricter than npm install in a repeatable build environment because it deletes any existing node_modules folder first and installs fresh every time it runs. The --ignore-scripts flag skips lifecycle scripts that sometimes trigger unexpected side effects during image builds.

With this order in place, Docker reuses the cached install layer on every push where only source files changed. The install only re-runs when package.json or package-lock.json actually changes.

Check this before moving on

  • COPY package.json package-lock.json ./ appears before RUN npm ci
  • COPY . . comes after the install step, not before
  • You’re using npm ci rather than npm install in the Dockerfile

Multi-stage builds: leave the compiler in the builder

A TypeScript project’s build step needs the TypeScript compiler, type declarations, and possibly other build-time tools. None of those belong in the production image. Multi-stage builds let you run the build in one stage and copy only the compiled output to a second, much smaller stage.

The FROM ... AS name syntax names a build stage. COPY --from=name copies files from that stage into the current one.

# Stage 1: build (includes TypeScript compiler and dev dependencies)
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build

# Stage 2: production image (no compiler, no dev dependencies)
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/dist ./dist
EXPOSE 3000
CMD ["node", "dist/server.js"]

The final image contains only the Alpine base, production dependencies, and the compiled output. The TypeScript compiler, source files, and all dev dependencies stay in the builder stage — they never make it into the image that gets pushed to your registry.

node:20-alpine is substantially smaller than node:20, which includes a full Debian system. Using Alpine in both stages compounds the savings. If your app uses native Node.js modules that don’t build cleanly on Alpine, node:20-slim is a reasonable middle ground — it’s still much smaller than the full image.

One thing to verify after switching to multi-stage: make sure your production dist/ entry point is correct and that no dev-only imports slipped into your production code. A tsc --noEmit check in CI before building usually catches these.

Running as a non-root user

Docker containers run as root by default. If a vulnerability in your application or its dependencies allows a process to escape the container’s isolation boundaries, root inside the container translates to root on the host. That’s a problem worth preventing.

The Docker best practices documentation is clear on this: if your service can run without elevated privileges, use the USER instruction to switch to a non-root user.

# Create a non-privileged system user and group
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

# Switch to it before CMD
USER appuser

The -S flag creates a system user with no login and no home directory by default. Alpine uses addgroup and adduser; Debian-based images use groupadd and useradd. The Docker documentation recommends assigning explicit numeric UIDs and GIDs — if you don’t, Docker assigns the next available ID non-deterministically, and that ID can differ between image rebuilds.

After the USER instruction, every subsequent RUN, CMD, and ENTRYPOINT runs as that user. Make sure your app’s files are readable by it. If the app writes to a directory at runtime — logs, temp files — adjust ownership before the USER switch:

RUN addgroup -S appgroup && adduser -S appuser -G appgroup
RUN chown -R appuser:appgroup /app
USER appuser

Before and after: the complete Dockerfile and .dockerignore

This is the before — the minimal Dockerfile most Node.js projects start with:

# Before: works, but costly
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD node server.js

And the after, applying everything from the previous sections:

# After: faster builds, smaller image, safer runtime
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --ignore-scripts
COPY . .
RUN npm run build

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev --ignore-scripts
COPY --from=builder /app/dist ./dist
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser
EXPOSE 3000
CMD ["node", "dist/server.js"]
What changed Before After
Base image node:20 (Debian) node:20-alpine
Cache busted by Any file change Lockfile or package.json changes only
Dev dependencies In production image Left in builder
Process user root appuser
Signal handling Shell form (PID 1 issues) Exec form (correct)

The .dockerignore below keeps your build context clean. Without it, Docker sends your entire project directory — including node_modules — to the build daemon before it even starts executing the Dockerfile. That transfer alone can take several seconds for a large project.

node_modules
.git
.env
.env.*
dist
coverage
*.log

The .dockerignore format works like .gitignore. If you’re missing it and your COPY . . instruction copies a local node_modules folder into the image, you can end up with a mix of locally-compiled native modules and the ones installed cleanly inside the container — a subtle way to get different behaviour in production than in development.

Mistakes that look harmless until they’re not

Shell form vs exec form for CMD. The before Dockerfile uses CMD node server.js — this is shell form. Docker runs it as /bin/sh -c node server.js, which means Node.js starts as a child of the shell process, not as PID 1. Shell processes don’t forward Unix signals, so when the orchestration layer sends SIGTERM to stop the container gracefully, Node.js never receives it. The container waits the full timeout (typically 10 seconds) and gets force-killed. The exec form — CMD ["node", "dist/server.js"] — runs Node.js directly as PID 1 and receives signals correctly. The Node.js runtime fundamentals post covers what PID 1 means for Node.js process lifecycle in more detail.

ADD instead of COPY for local files. ADD does more than copy: it can download from remote URLs and auto-extract tar archives. For copying local files into an image, COPY is the right instruction — it’s explicit and predictable. Using ADD for local files works, but it misleads anyone reading the Dockerfile and makes the intent unclear. The Dockerfile reference recommends COPY for local file copies and ADD only when you specifically need its remote or extraction behaviour.

npm install instead of npm ci. npm install can modify the lockfile and may install versions slightly different from what the lockfile specifies. In a Dockerfile, you want the build to be repeatable and fast. npm ci installs exactly what the lockfile says, removes any existing node_modules before installing, and is faster in CI contexts. The only time npm ci doesn’t work is when there is no lockfile — which, for a project you’re containerising, should not be the case.

A leaner image is not a weaker one

None of these changes reduce your application’s capabilities. Multi-stage builds produce the same running application — they just exclude the files that were never needed at runtime. A non-root user doesn’t restrict what your code can do unless your code was relying on root privileges, which it shouldn’t be. Layer caching makes builds faster without changing what gets deployed.

Start with the COPY package.json trick — it’s the single change with the biggest day-to-day impact. Add multi-stage builds when you have a TypeScript or otherwise compiled project. Add the non-root user before you push to production. The .dockerignore file should exist alongside any project that already has a .gitignore.

If you’re building a REST API that runs inside this container, the Express project structure post covers the application layer. The REST API with Node.js and Express post is a good starting point if you’re setting up the API itself.

Sources