The deployment pipeline was the bottleneck. Developers were waiting 15-20 minutes for feedback on simple changes. The culprit wasn't the test suite—it was the build process itself re-installing dependencies on every commit.
The Bottleneck
In a monolithic Node.js application, our Dockerfile was simple but inefficient. Every time a developer changed a single line of code in `src/`, Docker would invalidate the cache for that layer and all subsequent layers.
Because the COPY . . command happened before the dependency installation, we were forcing npm install to run from scratch on every build, wasting bandwidth and time.
The "Naive" Approach
This was the state of the Dockerfile when we audited the repository. Notice how the source code copy invalidates the cache for the install step immediately after it.
FROM node:18-alpine
WORKDIR /app
# Bad: Copying everything invalidates cache on ANY file change
COPY . .
# This runs on every commit, taking 4-5 minutes
RUN npm install
CMD ["node", "index.js"]
The Optimization: Layer Ordering
Docker caches layers based on the checksum of the files added. By copying only the package.json and package-lock.json files first, we can run the install step.
Since these two files change infrequently compared to source code, Docker will use the cached layer for npm install 90% of the time.
The Implementation
We also introduced a Multi-stage Build. This allows us to install "devDependencies" (like TypeScript compilers or linters) in a build stage, but leave them out of the final production image, reducing image size and attack surface.
# --- Stage 1: Builder ---
FROM node:18-alpine AS builder
WORKDIR /app
# 1. Copy Manifests First
COPY package*.json ./
# 2. Install dependencies (Cached unless package.json changes)
RUN npm ci
# 3. Copy Source Code (Cache bust happens here)
COPY . .
# 4. Build (TypeScript compilation, etc.)
RUN npm run build
# --- Stage 2: Runner ---
FROM node:18-alpine AS runner
WORKDIR /app
ENV NODE_ENV production
# Copy only necessary files from builder
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package*.json ./
COPY --from=builder /app/node_modules ./node_modules
CMD ["node", "dist/index.js"]
The Results
We benchmarked the pipeline over a week of active development.
- Avg Build Time (Before): 14m 30s
- Avg Build Time (After): 5m 10s
- Storage Saved: Production images reduced from 1.2GB to 350MB
Lesson: Order matters. Treat your Dockerfile instructions like a waterfall; place the least frequently changed items at the top to maximize cache hits.