In this guide, we will walk through a real-world case study of optimising a Docker image for a Node.js Todo application. We'll start with a basic (and bloated) implementation and iterate through three levels of optimisation, focusing on size, performance and security.
We are working with a Full Stack Todo App (React Frontend + Node.js Backend).
The Orchestration: Docker Compose
Before diving into the Dockerfiles, it's important to understand how we run the app. We use Docker Compose to spin up the entire stack locally.
If you found this helpful, please like and share to support the content!
Always curious to understand the concept, learning by breaking and fixing, and passionate about sharing knowledge with the community.Get in touch with me→
In the early stages (V0-V2), our compose.yaml is designed for Development:
As we optimise the image for production (V3), this architecture significantly changes to a Single Container model.
Initially, we approached the Dockerfile with the goal of "just getting it to run." We used a single stage to build the frontend and run the backend.
The Dockerfile (V0)
FROM node:22
WORKDIR /usr/local/app
COPY . .
# Build Frontend
WORKDIR /usr/local/app/client
RUN npm install
RUN npm run build
# Setup Backend
WORKDIR /usr/local/app/backend
RUN npm install
# Move Assets
RUN mkdir -p src/static && cp -r /usr/local/app/client/dist/* src/static/
ENV NODE_ENV=production
EXPOSE 3000
CMD ["node", "src/index.js"]Orchestration Note
Running this with docker compose up in development actually ignores this Dockerfile's production instructions because our compose.yaml overrides the command to run npm run dev. This creates a disparity between "what runs in dev" vs "what runs in prod."
The Result (V0)
Before applying the fix, let's understand the concept. Multi-Stage Builds allow you to use multiple FROM instructions in a single Dockerfile. Each instruction starts a new stage of the build, allowing you to selectively copy artifacts from one stage to another, leaving behind everything you don't need in the final image.
Criteria for Splitting Stages
How do you decide when to create a new stage?
Best Practices
AS build or AS dev for clarity.node:22) and a light one for running (node:22-alpine or distroless). To solve the bloat, we separated the "build environment" from the "runtime environment" using Docker Multi-Stage Builds.
The Changes
Orchestration Note
Our compose.yaml was updated to target specific stages (target: backend-dev and target: client-dev). This allows us to keep using the same Dockerfile for both our optimised production builds AND our hot-reloading development environment.
The Result (V1)
node:22) is based on Debian and weighs ~1GB on its own.To tackle the base image size, we moved to Alpine Linux, a lightweight security-oriented distribution.
The Changes
node:22-alpine (instead of node:22)..git and local node_modules.RUN apk add --no-cache --virtual .build-deps python3 make g++ py3-setuptools && \
npm ci --only=production && \
npm cache clean --force && \
apk del .build-depsRationale
musl instead of glibc and busybox, usually under 20MB compressed.npm install step (to compile native modules like sqlite3) and removed them in the same layer~/.npm cache which can hold hundreds of MBs.node_modules, .git, .env) from being sent to the Docker daemon.Speed: Reduced "build context" size means faster upload times to the daemon.
Safety: Prevents overwriting container-compatible modules with local OS-specific binaries.
Security: Stops accidental leakage of local secrets or git history into the image layers.
The Result (V2)
For the highest level of security, we adopted Google's Distroless images and enforced a Non-Root User.
The Changes
gcr.io/distroless/nodejs22-debian12.node:22-bookworm-slim to ensure compiled binaries were compatible with the runtime.2. Non-Root User:
Orchestration Change (Crucial)
At this stage, we cannot simply run docker compose up with the old config because the V3 image is a single, bundled artifact. It doesn't have a shell, so it can't run the npm run dev commands our dev compose file expects.
We created a new compose.prod.yaml for this:
final stage.app service (combines frontend+backend).###################################################
# Stage: base
###################################################
# Use Debian Slim for build to match Distroless (glibc)
FROM node:22-bookworm-slim AS base
WORKDIR /usr/local/app
################## CLIENT STAGES ##################
###################################################
# Stage: client-base
###################################################
FROM base AS client-base
COPY client/package.json client/package-lock.json ./
RUN npm install && npm cache clean --force
COPY client/.eslintrc.cjs client/index.html client/vite.config.js ./
COPY client/public ./public
COPY client/src ./src
###################################################
# Stage: client-dev
###################################################
FROM client-base AS client-dev
CMD ["npm", "run", "dev"]
###################################################
# Stage: client-build
###################################################
FROM client-base AS client-build
RUN npm run build
###################################################
################ BACKEND STAGES #################
###################################################
###################################################
# Stage: backend-base
###################################################
FROM base AS backend-dev
# Install build tools for native modules (sqlite3/mysql) on Debian
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
COPY backend/package.json backend/package-lock.json ./
RUN npm install && npm cache clean --force
COPY backend/spec ./spec
COPY backend/src ./src
CMD ["npm", "run", "dev"]
###################################################
# Stage: test
###################################################
FROM backend-dev AS test
RUN npm run test
###################################################
# Stage: build-prod
###################################################
# Intermediate stage to install prod dependencies
FROM base AS build-prod
WORKDIR /usr/local/app
COPY /usr/local/app/package.json /usr/local/app/package-lock.json ./
# Install build tools again for prod deps
RUN apt-get update && apt-get install -y --no-install-recommends python3 make g++ && rm -rf /var/lib/apt/lists/*
RUN npm ci --only=production && npm cache clean --force
###################################################
# Stage: final
###################################################
# Distroless Runtime
FROM gcr.io/distroless/nodejs22-debian12 AS final
ENV NODE_ENV=production
WORKDIR /usr/local/app
# Copy prod dependencies
COPY /usr/local/app/node_modules ./node_modules
# Copy app source
COPY backend/src ./src
# Copy built frontend
COPY /usr/local/app/dist ./src/static
# Run as non-root user (distroless provides 'nonroot' user with uid 65532)
USER nonroot
EXPOSE 3000
CMD ["src/index.js"]The Final Result (V3)
Cannot exec into the Container as the base image is distroless
While V3 (Distroless) is the paramount of open-source security, enterprise and government environments often require an even stricter standard: Hardened Images.
Optimising for this level ("V4") means moving beyond just "minimal" to "compliant" and "zero-CVE".
Options to Consider
latest tags only; production stability requires a subscription.When to choose V4?
If you are operating in a highly regulated industry (Finance, Healthcare, Defence), V3 might not be enough. You may need the Zero-CVE guarantee and Contractual SLA that comes with these commercial hardened image providers.
| Version | Base Image | Size | Security Check | Orchestration |
|---|---|---|---|---|
| V0 | node:22 | 1.4 GB | ❌ Fails | compose.yaml (Dev) |
| V1 | node:22 | 1.23 GB | ⚠️ Poor | compose.yaml (Dev targets) |
| V2 | node:22-alpine | 257 MB | ✅ Good | compose.yaml (Dev targets) |
| V3 | distroless/debian12 | 189 MB | 🏆 Best | compose.prod.yaml (Single Prod Container) |
By iterating through these steps, we reduced the image size by 86%, significantly improved the security posture, and established a clear separation between our Development and Production orchestration workflows.

Delete your Dockerfile. Learn how Paketo Buildpacks use the pack CLI to create secure, SBOM-ready Node.js images automatically.

Cloud Native Buildpacks eliminate the Dockerfile entirely. Discover the 5-phase lifecycle, core components, and advanced security mechanics that make CNB the enterprise standard for container builds.