Writing Dockerfiles That Build Fast
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
Git Tags, Drone CI, and Watchtower — A Simple Deployment Pipeline
How I moved from Jenkins to Drone CI with git tags and Watchtower for a lightweight, reliable deployment pipeline.
Migrating from Nginx to Caddy — Why I Switched and Never Looked Back
How Caddy replaced Nginx as my reverse proxy — automatic HTTPS, wildcard domains, and a config file that actually makes sense.
Streaming IP Cameras to the Browser with RTSP, WebRTC, and DDNS
How I stream RTSP cameras to the browser using RTSPtoWeb and WebRTC — plus how DDNS makes it accessible from anywhere.