Optimizing Docker Image Size: Multi-Stage Builds, Layer Order, and Distroless Images
Shrink a Node.js Docker image from 1.67 GB to 217 MB with multi-stage builds, cache-friendly layer order, .dockerignore and distroless images.

Your API is a few hundred lines of TypeScript, and the image you push for it is 1.67 GB. Every CI run pulls it, every new node in the cluster downloads it before it can start a pod, and your security scanner reports a long list of packages you never asked for: compilers, git, curl, a full Debian userland.
None of that is needed to run your code. Most of it gets into the image through three habits: starting from the biggest base image, copying the whole project in one step, and shipping the tools you used to build the app alongside the app itself.
This post takes one small Node.js service through five Dockerfiles, measures each one, and shows which change moves the number. At the end there is a short Python version of the same pattern.
Quick Answer
To shrink a Docker image, start from a smaller base image (-slim, -alpine or distroless), use a multi-stage build so compilers and dev dependencies stay in a stage you throw away, and copy only the compiled output and production dependencies into the final stage. Order your Dockerfile so dependency files are copied and installed before your source code, so a code change does not invalidate the dependency layer. Add a .dockerignore so node_modules, .git and secrets never enter the build context. On the example in this post, those changes took the image from 1.67 GB to 217 MB on disk.
What Makes an Image Big
A Docker image is your app's filesystem plus the command that starts it, packaged so it runs the same on any machine. A container is a running copy of an image. You push images to a registry such as Docker Hub, Amazon ECR or Google Artifact Registry, and every server that runs your app pulls them from there, which is why their size shows up in deploy times.
Inside, an image is a stack of read-only layers. Each RUN, COPY and ADD instruction in your Dockerfile adds one, and the final size is the sum of all of them, including everything in the base image you start FROM.
Deleting a file in a later layer does not make the image smaller. The bytes still live in the layer that created them. The base image is usually the largest layer you have. On the machine used for this post, node:22 alone is 1.64 GB on disk and node:22-slim is 349 MB, before a single line of your code is added.
Layers are also the unit of caching. When Docker rebuilds, it reuses each layer until it reaches the first instruction whose inputs changed, and then reruns that step and every step after it. That is why the order of instructions matters for build speed, even though it does not change the final size.
The Problem
Here is the service: an Express app written in TypeScript, compiled to dist/ with tsc.
import express from "express";
const app = express();
app.get("/health", (_req, res) => {
res.json({ status: "ok" });
});
app.listen(3000, () => console.log("listening on 3000"));And here is the Dockerfile most projects start with:
FROM node:22
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["npm", "start"]It works, and it has four problems:
node:22is the full Debian-based image, with build tools and libraries the app never uses at runtime.COPY . .sends everything in the folder, including your localnode_modules,.gitand any.envfile. In this build that one layer was 30.4 MB, asdocker historyshows. It also carries your Mac'snode_modules, which is why thenpm installlayer after it is only 3.78 MB, and why native modules can end up with macOS binaries inside a Linux image.- TypeScript,
@types/*and every other dev dependency stay in the final image. - Because the source is copied before
npm install, changing one line of code reinstalls every dependency on the next build.
Build it and check the size:
docker build -f Dockerfile.naive -t orders-api:naive .
docker images orders-api:naive
docker history orders-api:naiveThe result is 1.67 GB on disk, or 408 MB compressed. The compressed figure is what your registry stores and what a server downloads on pull.
The Solution
Each step below builds on the one before it. All sizes come from docker images on Docker 29.7.2, linux/arm64, in October 2026. Exact numbers will differ slightly on amd64 and as base images are updated, but the proportions hold.
The on-disk figures come from the DISK USAGE column. With the containerd image store, which Docker 29 uses, that number counts the unpacked layers plus the compressed download kept next to them. node:22-slim shows 349 MB, of which about 263 MB is the unpacked filesystem and 82.6 MB is the compressed layers. The older image store reports only the unpacked size, so your on-disk numbers may be smaller. The compressed figure is the same on both, so compare that one if yours look different.
Step 1: Add a .dockerignore
Before changing the Dockerfile, stop sending things that should never be in an image. A .dockerignore file in the build context works like .gitignore:
node_modules
dist
.git
.env
*.log
Dockerfile*This keeps your host's node_modules (which may contain binaries built for macOS, not Linux) out of the build, and it keeps secrets in .env from being baked into a layer. The Docker build context documentation covers the full pattern syntax.
Step 2: Pick a smaller base and order layers for the cache
Switch to node:22-slim and copy the dependency manifests before the source code:
FROM node:22-slim
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["node", "dist/server.js"]npm ci installs exactly what package-lock.json specifies, which is what you want in a reproducible build. See the npm ci reference for how it differs from npm install.
The image drops to 399 MB. Almost all of that saving comes from the base image, not from anything else in this Dockerfile.
The layer order pays off on the next build. After changing one line in server.ts, the rebuild log shows:
#7 [3/6] COPY package.json package-lock.json ./
#7 CACHED
#8 [4/6] RUN npm ci
#8 CACHED
#9 [5/6] COPY . .
#9 DONE 0.0s
#10 [6/6] RUN npm run build
#10 DONE 1.7sThe dependency install is skipped. With the naive Dockerfile, the same one-line change reran COPY . ., npm install and the build from scratch.

The same rule applies to any Dockerfile. Put the things that change least at the top of the Dockerfile and the things that change on every commit at the bottom. The Docker build cache guide explains what invalidates a layer.
Step 3: Split the build into stages
The slim image still carries TypeScript and every dev dependency, because they were needed to compile. A multi-stage build uses one stage to compile and a fresh stage to run, copying across only what the second stage needs:
# ---- build stage: has TypeScript and every dev dependency ----
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- runtime stage: production dependencies and compiled JS only ----
FROM node:22-slim
WORKDIR /app
ENV NODE_ENV=production
COPY package.json package-lock.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=build /app/dist ./dist
USER node
CMD ["node", "dist/server.js"]Only the last FROM becomes your image. Everything in the build stage is discarded once COPY --from=build has taken dist/ out of it. npm cache clean runs in the same RUN as the install, because cleaning up in a separate RUN would leave the cache in the layer below.
This version is 355 MB. It also runs as the unprivileged node user, which the official image provides, instead of root.
The same Dockerfile on node:22-alpine comes to 243 MB. Alpine uses musl instead of glibc, which matters for some native modules, as the Trade-offs section explains.
Step 4: Run on a distroless image
Distroless images, maintained in the GoogleContainerTools distroless project, contain your language runtime and its system libraries, and nothing else. There is no shell, no package manager and no npm. Since npm is not available in the final stage, production dependencies are installed in their own stage and copied across:
# ---- build stage ----
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- production dependencies only ----
FROM node:22-slim AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
# ---- runtime: no shell, no npm, no package manager ----
FROM gcr.io/distroless/nodejs22-debian12:nonroot
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=build /app/dist ./dist
CMD ["dist/server.js"]The distroless Node.js image already sets node as its entrypoint, so CMD only needs the script path. The :nonroot tag runs the process as a non-root user by default.

The result is 217 MB on disk and 52.9 MB compressed. The app still answers on /health, but anything that expects a shell fails:
docker run -d --rm -p 3000:3000 --name orders orders-api:distroless
curl localhost:3000/health
docker exec orders sh -c 'echo hi'{"status":"ok"}
OCI runtime exec failed: exec failed: unable to start container process: exec: "sh": executable file not found in $PATHAn attacker who gets code execution inside this container has no shell, no curl and no package manager to work with. They still have Node, which can open network connections and read any file the process can read, so keep patching the base image and running as non-root.
Step 5: Measure, don't guess
Here is the whole progression, all measured on the same app:
| Dockerfile | Size on disk | Compressed |
|---|---|---|
| Naive, node:22, COPY . . | 1.67 GB | 408 MB |
| node:22-slim, cache-friendly order | 399 MB | 91.4 MB |
| Multi-stage on node:22-slim | 355 MB | 81.1 MB |
| Multi-stage on node:22-alpine | 243 MB | 62.3 MB |
| Multi-stage on distroless | 217 MB | 52.9 MB |

The biggest single win is the base image. Multi-stage builds and distroless matter more for what they remove (compilers, shells, dev dependencies) than for the megabytes alone.
To see where the space goes in your own image, run docker history <image> for a per-layer breakdown, or use dive to browse each layer's files interactively.
The same pattern in Python
The idea carries over. Build a virtualenv in one stage and copy only that virtualenv into a clean slim image:
# ---- build stage: install dependencies into a virtualenv ----
FROM python:3.12-slim AS build
WORKDIR /app
RUN python -m venv /opt/venv
ENV PATH="/opt/venv/bin:$PATH"
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
# ---- runtime stage: copy only the virtualenv and the code ----
FROM python:3.12-slim
WORKDIR /app
ENV PATH="/opt/venv/bin:$PATH" PYTHONDONTWRITEBYTECODE=1 PYTHONUNBUFFERED=1
COPY --from=build /opt/venv /opt/venv
COPY app.py .
USER nobody
CMD ["gunicorn", "-b", "0.0.0.0:8000", "app:app"]For a small Flask app served by gunicorn, a single-stage build on python:3.12 came to 1.63 GB. This version is 230 MB on disk, or 49.6 MB compressed. If a dependency needs a compiler, install build-essential in the build stage only, and it never reaches the runtime image. gunicorn has to be in requirements.txt, because the runtime stage has only what was installed into the virtualenv.
Keeping the build cache in CI
Layer order only helps when the previous layers are still there. Two more settings keep builds fast when they are not.
A BuildKit cache mount keeps npm's download cache between builds, even when package-lock.json changes and npm ci has to rerun:
FROM node:22-slim AS build
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm npm ciThe cache lives on the builder, outside the image, so it adds nothing to the image size.
A fresh CI runner starts with no layers at all, so every build there is a cold build unless you export the cache and import it on the next run. With docker buildx build, store it in your registry:
docker buildx build \
--cache-from type=registry,ref=registry.example.com/orders-api:buildcache \
--cache-to type=registry,ref=registry.example.com/orders-api:buildcache,mode=max \
-t registry.example.com/orders-api:latest --push .mode=max also saves the layers of the build and deps stages, which the final image does not contain. On GitHub Actions, type=gha keeps the same cache in the Actions cache instead. The Docker cache backends documentation lists every option, and our CI/CD playbook covers the pipeline around it.
Trade-offs
Distroless is harder to debug. With no shell you cannot docker exec in and look around. The distroless project publishes :debug tags that add a BusyBox shell for this, and docker debug can attach a toolbox to a running container without changing the image. docker debug ships with Docker Desktop, so on a Kubernetes cluster use kubectl debug to attach an ephemeral debug container instead. Decide on one of these before you need it during an incident.
Alpine is not always a free win. It uses musl libc instead of glibc. Pure JavaScript runs fine, but native modules and some prebuilt binaries can fail or behave differently. The official Node image README calls this out. If your dependencies include native code, test on Alpine before switching, or stay on -slim.
Multi-stage Dockerfiles are longer. Three stages are more to read than six lines, and a mistake in a COPY --from path shows up only at runtime. Keep stage names descriptive and comment what each one is for.
Distroless limits what you can install. Distroless publishes images per runtime and Debian release, so you depend on its release cadence and cannot apt-get install a missing system library into the final stage. If your app shells out to a CLI tool, distroless is the wrong choice.
Small does not matter everywhere. For local development, a dev container or a one-off internal tool, a full image with a shell and build tools is often more convenient. Optimise the images that are pulled often: the ones your CI, autoscaler and production nodes download.
Conclusion
On Monday, run docker images and docker history on the image your busiest service ships, and look at the base image and the largest layer. Add a .dockerignore, move the package.json copy above your source copy, and switch the base to -slim. Those three changes took this example from 1.67 GB to 399 MB. Then split the build into stages, and move to distroless once your team knows how it will debug a container without a shell. Measure after every change, because your stack will give different numbers from the ones here.
If you want help trimming the images in your own pipeline, Contact us.

Aarav Sharma
Lead Software Engineer
Aarav leads product engineering at Matlab Infotech, where he has shipped mobile and web platforms across healthcare, fintech, and SaaS. He writes about pragmatic engineering and shipping fast without cutting corners.
