William Mulianto

Writing Dockerfiles That Build Fast

· 3 min read

Slow Docker builds add up. Every push, every deploy, every time you test a change locally. Most of the time, the fix is just ordering your Dockerfile better. Here’s what I do for Node.js projects.

Layer Caching — Order Matters

Docker caches each layer. If a layer hasn’t changed, it skips it. The trick is putting things that change least at the top and things that change most at the bottom.

Bad — installing dependencies every time you change any file:

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/index.js"]

COPY . . copies everything, including your source code. Any code change invalidates the cache, and npm install runs again even if your dependencies haven’t changed.

Better — copy package files first:

FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/index.js"]

Now npm ci only re-runs when package.json or package-lock.json changes. Source code changes skip straight to COPY . . and npm run build. This alone can cut build times significantly.

Use npm ci Instead of npm install

npm ci is designed for CI/CD and Docker. It installs exactly what’s in your lock file, skips resolving versions, and is faster. It also deletes node_modules before installing, so you get a clean slate every time.

Multi-Stage Builds

Your final image doesn’t need dev dependencies, build tools, or source files. Multi-stage builds let you build in one stage and copy only the output to a smaller final image.

# Build stage
FROM node:20-alpine AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build

# Production stage
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=build /app/dist ./dist
CMD ["node", "dist/index.js"]

The build stage has everything — TypeScript, dev dependencies, build tools. The production stage only has production dependencies and the compiled output. The final image is much smaller.

.dockerignore

Without a .dockerignore, COPY . . sends everything to the Docker daemon — node_modules, .git, test files, local env files. This slows down the build context transfer and can bloat your image.

node_modules
.git
.env
.env.*
dist
*.md
.vscode
coverage
.nyc_output

This keeps the build context small and fast.

Use Alpine Images

node:20 is around 1 GB. node:20-alpine is around 130 MB. Unless you need specific system libraries that Alpine doesn’t have, use Alpine. Your builds will be faster and your images will be smaller.

Cache Mounts for Package Managers

Docker BuildKit supports cache mounts that persist the npm cache between builds:

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

This keeps the npm cache across builds so packages don’t need to be re-downloaded every time. Useful when you’re iterating on dependencies locally.

ARG for Build-Time Variables

If you need to pass variables at build time — like a version number or API URL — use ARG:

ARG APP_VERSION=dev
ENV APP_VERSION=$APP_VERSION
RUN npm run build
docker build --build-arg APP_VERSION=v1.2.0 .

This avoids hardcoding values and keeps your Dockerfile reusable across environments.

Related