Your Docker Image Is 1.2GB. Here Is How To Get It Under 80MB. | The Practical Developer Bloated Docker images are the silent tax on every team that ships containers. Slow CI. Slow deploys. Bigger attack surface. Bigger registry bill. And almost always, it’s fixable in an afternoon.
I took a real Node.js + TypeScript service we ship to production, started from the naive Dockerfile most teams write, and walked it down from 1.2GB to 78MB. Same app, same behavior, six steps, all measured on the same machine. Here is exactly what moved the needle.
The starting point: 1.2GB
This is the Dockerfile most teams begin with. It works. It is also wasteful in almost every line.
FROM node:22
WORKDIR /app<br>COPY . .<br>RUN npm install<br>RUN npm run build
EXPOSE 3000<br>CMD ["npm", "start"]<br>Build it and check the size:
$ docker build -t app:naive .<br>$ docker images app:naive<br>REPOSITORY TAG SIZE<br>app naive 1.21GB<br>1.21GB to ship a service that produces about 4MB of compiled JavaScript. Let’s fix it.
Step 1: Switch the base image — 1.21GB → 412MB
The node:22 tag is Debian-based and includes a full toolchain you do not need at runtime. The slim variant strips most of it.
FROM node:22-slim
ImageSizenode:221.21GBnode:22-slim412MBnode:22-alpine178MB<br>Alpine is even smaller, but it uses musl libc instead of glibc. Most pure-JS apps run fine on it, but anything with native modules (bcrypt, sharp, node-gyp builds) needs extra care, and some packages have subtle musl bugs. I default to slim and reach for alpine only when I know the dependency tree is clean.
For this post, we will keep going with slim to stay realistic about real-world apps.
Step 2: Use a .dockerignore — 412MB → 388MB
COPY . . happily copies your node_modules, .git, build artifacts, local .env files, IDE folders, and test fixtures into the image. Even if a later step overwrites node_modules, the layer is already in the image history.
Create a .dockerignore:
node_modules<br>npm-debug.log<br>.git<br>.gitignore<br>.env*<br>.vscode<br>.idea<br>coverage<br>dist<br>build<br>*.md<br>test<br>__tests__<br>Dockerfile*<br>.dockerignore<br>Small win on size, big win on rebuild speed and security. Your local .env.development is no longer hiding inside a layer that gets pushed to a public registry.
Step 3: Multi-stage build — 388MB → 198MB
You need TypeScript, eslint, the test framework, and probably a hundred transitive dev dependencies to build the app. You do not need any of them to run it.
A multi-stage build compiles in one image and copies only the artifacts into a second, clean image:
# ---- builder ----<br>FROM node:22-slim AS builder<br>WORKDIR /app
COPY package*.json ./<br>RUN npm ci
COPY . .<br>RUN npm run build<br>RUN npm prune --omit=dev
# ---- runtime ----<br>FROM node:22-slim<br>WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules<br>COPY --from=builder /app/dist ./dist<br>COPY --from=builder /app/package.json ./package.json
EXPOSE 3000<br>CMD ["node", "dist/index.js"]<br>Two things doing real work here:
npm ci instead of npm install. It uses package-lock.json directly, is faster, and is reproducible. Use it in CI and in Docker. Always.
npm prune --omit=dev strips dev dependencies after the build. On a typical TypeScript service, that is half of node_modules gone.
Step 4: Layer caching that actually works — same size, 5x faster rebuilds
This is not about size, but it is the single biggest CI win. Most Dockerfiles invalidate npm ci on every code change because they COPY . . before installing. Order matters.
Already shown above, but worth calling out: copy package*.json first, install, then copy the rest. Now npm ci is cached as long as your dependencies don’t change.
COPY package*.json ./<br>RUN npm ci<br>COPY . .<br>RUN npm run build<br>On a project with ~600 dependencies, this took our cold rebuild from 94 seconds to 18 seconds when only application code changed. Multiplied by every PR, every day, every developer.
Step 5: Switch to Alpine for the runtime stage — 198MB → 96MB
We can keep the Debian-based builder (compatible with everything) and switch only the runtime to Alpine. The compiled JS does not care about the base OS at runtime as long as no native binaries are calling glibc-specific symbols.
# ---- builder ----<br>FROM node:22-slim AS builder<br>WORKDIR /app<br>COPY package*.json ./<br>RUN npm ci<br>COPY . .<br>RUN npm run build<br>RUN npm prune --omit=dev
# ---- runtime ----<br>FROM node:22-alpine<br>WORKDIR /app<br>COPY --from=builder /app/node_modules ./node_modules<br>COPY --from=builder /app/dist ./dist<br>COPY --from=builder /app/package.json ./package.json<br>EXPOSE 3000<br>CMD ["node", "dist/index.js"]<br>If you have native modules, build them in a stage that matches the runtime libc. For Alpine that means node:22-alpine as the builder too, plus apk add --no-cache python3 make g++ to compile, then a clean runtime stage.
Step 6: Drop Node entirely with distroless — 96MB → 78MB
Google’s distroless images contain just the Node runtime and its TLS roots. No shell, no package manager, no apt, no curl. If something pops a shell inside your container,...