Streamlining Delivery: Reducing Docker Image Size by 61%.

A technical deep dive into optimizing a Node.js microservices architecture by implementing multi-stage Docker builds, resulting in faster CI/CD pipelines, lower bandwidth costs, and a reduced security attack surface.

Illustrative example based on common patterns from similar engagements — client details anonymized.

Image Size Reduction

Slashed average container image size from 1.2GB to under 470MB, a 61% reduction in storage footprint.

Pipeline Speed

Decreased GitHub Actions build and push times by 40%, accelerating the feedback loop for developers.

Security Posture

Eliminated build tools and development dependencies from production images, severely reducing vulnerabilities.

Bloated Containers and Sluggish Pipelines

The client, a fast-paced fintech startup, had embraced microservices and Docker early in their journey. Their backend consisted of approximately 15 distinct Node.js (TypeScript) services deployed on Amazon EKS (Elastic Kubernetes Service).

While Docker provided the necessary isolation and consistency between environments, they were running into significant operational friction. The primary issue was the size of their Docker images. A typical microservice image was weighing in at over 1.2GB.

These bloated images were causing cascading problems across their infrastructure. CI/CD pipelines in GitHub Actions were taking over 15 minutes to build, tag, and push images to the Elastic Container Registry (ECR). Deployments to EKS were sluggish because nodes took a long time to pull the massive images. Furthermore, their ECR storage costs and cross-AZ data transfer out costs were steadily climbing.

Analyzing the Dockerfiles

An audit of their existing Dockerfile configurations quickly revealed the root causes of the bloat. The standard Dockerfile they were using for all services looked something like this:

FROM node:18

WORKDIR /usr/src/app

COPY package*.json ./
RUN npm install

COPY . .
RUN npm run build

EXPOSE 3000
CMD ["npm", "run", "start"]

This seemingly innocent file contained several fatal flaws for production use:

  • Heavy Base Image: node:18 is based on Debian Bullseye, which includes hundreds of megabytes of OS utilities, compilers, and libraries completely unnecessary for running a compiled Node app.
  • Dev Dependencies in Prod: npm install without flags installed massive development dependencies (like TypeScript, Jest, ESLint) directly into the final image.
  • Source Code Remnants: The original TypeScript source files remained in the image even after being compiled to JavaScript in the build step.

Multi-Stage Builds to the Rescue

To solve these issues, I introduced multi-stage Docker builds combined with a highly optimized Alpine Linux base image. Multi-stage builds allow you to use multiple FROM statements in a single Dockerfile. Each FROM begins a new stage of the build. You can selectively copy artifacts from one stage to another, leaving behind everything you don't need in the final image.

Here is the revised architecture of the Dockerfile:

# Stage 1: Build Environment
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
# Install ALL dependencies (including devDependencies)
RUN npm ci
COPY . .
# Compile TypeScript to JavaScript
RUN npm run build

# Stage 2: Production Environment
FROM node:18-alpine AS runner
WORKDIR /app
# Set environment to production
ENV NODE_ENV=production
COPY package*.json ./
# Install ONLY production dependencies
RUN npm ci --only=production
# Copy ONLY the compiled javascript from the builder stage
COPY --from=builder /app/dist ./dist

EXPOSE 3000
CMD ["node", "dist/main.js"]

Key Improvements:

  • Alpine Base: Switching from node:18 to node:18-alpine instantly shaved over 700MB off the underlying OS layer.
  • Separation of Concerns: The builder stage contains all the heavy compilation tools and source code. The runner stage is kept pristine, receiving only the final compiled assets.
  • NPM CI & Prod Flags: Using npm ci ensures deterministic builds based on the lockfile, and --only=production prevents hundreds of megabytes of dev-dependencies from entering the final layer.

Leaner, Faster, Safer

The rollout of the new multi-stage builds across the 15 microservices yielded immediate and measurable benefits:

1. Massive Storage Reduction (61%): The average image size dropped from 1.2GB to 470MB. This drastically reduced ECR storage costs and lowered the cross-AZ data transfer fees incurred when EKS nodes pull images.

2. Accelerated Deployments: Because the nodes had to download 61% less data, pod startup times in EKS improved significantly. The total time from a developer merging a PR to the code running live in production dropped by 40%.

3. Enhanced Security Posture: By removing development tools, compilers (like `tsc`), and extraneous OS packages (via Alpine), we drastically reduced the attack surface of the production containers. Container security scanning tools (like Trivy) reported a 75% reduction in flagged CVEs simply because the vulnerable packages were no longer present in the final image.

Optimizing Dockerfiles is often overlooked as a premature optimization, but in a microservices environment, container bloat scales linearly. Implementing multi-stage builds fundamentally improved the performance and economics of their entire delivery pipeline.

Are your deployments crawling?

Let's audit your CI/CD pipelines and container strategy.

Get in touch