Your Docker Image Is 1.2GB. Here's the 180MB Version.
Most Docker images are huge for three fixable reasons: no multi-stage build, a fat base image, no .dockerignore. Here's the fix, step by step.
The first time I ran docker images on a Node API I'd just containerised, the number genuinely embarrassed me. 1.2GB. For an Express app that was maybe 400 lines of my own code.
I remember staring at it thinking: what is in there? I didn't write 1.2GB of anything.
That question is worth answering, because the answer is almost always the same three things, and all three are fixable in about twenty minutes. This post is that walkthrough — the naive Dockerfile, why it's so big, and the version I'd actually ship.
First, let's find out what's actually in there
Here's the Dockerfile almost everyone writes first. It works. It's also the problem.
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
CMD ["node", "dist/main.js"]
Nothing here is wrong, exactly. It builds, it runs, it ships. But four things are quietly inflating it:
node:20is a full Debian image. Around 1.1GB before you add a single line of your own code. It ships a complete Linux userland — apt, Python, build toolchains, man pages. You are shipping a general-purpose operating system to run one JavaScript process.npm installinstalls devDependencies. TypeScript, ESLint, Jest, your entire test suite, webpack or Vite, all of it. None of it runs in production. All of it ships.COPY . .copies everything. Including.git— which on a busy repo can be hundreds of megabytes of history — plus your localnode_modules,.envfiles, screenshots, logs, coverage reports.- The build tooling stays in the final image. You needed TypeScript to produce
dist/. You do not need TypeScript to rundist/. But it's still in there.
That last bullet is the one worth sitting with. Your application code is often the smallest thing in your application image.
Fix one: stop copying junk in
This is the cheapest win and most people skip it. Create a .dockerignore next to your Dockerfile:
node_modules
.git
.env*
dist
coverage
*.log
.vscode
README.md
Two reasons this matters more than it looks.
The obvious one: less stuff copied, smaller image.
The non-obvious one: COPY . . invalidates your build cache every time any file changes. Edit the README, and Docker re-runs your entire npm install. Ignoring the noisy files means your cache survives longer, and your builds get dramatically faster — which you'll feel on every CI run, not just once.
Also, .env in a Docker image is a real security problem. Anyone who can pull that image can read your secrets with docker history. .dockerignore is the simplest guard against doing that by accident.
Fix two: multi-stage builds
This is the big one. The idea is simple: build in one image, run in a different, much smaller one, and copy only the finished output across.
# ---- build stage ----
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build
# ---- runtime stage ----
FROM node:20-alpine AS runtime
WORKDIR /app
ENV NODE_ENV=production
COPY package*.json ./
RUN npm ci --omit=dev && npm cache clean --force
COPY --from=builder /app/dist ./dist
USER node
CMD ["node", "dist/main.js"]
Everything in the builder stage — TypeScript, the full dependency tree, source files, build caches — is thrown away. Only dist/ crosses the boundary.
A few details in there that actually matter:
npm ci, notnpm install. It installs exactly what's in your lockfile, and it's faster.npm installcan silently update your lockfile mid-build, which means your CI image and your teammate's image aren't the same thing.--omit=devin the runtime stage. This is where most of the savings live. On a typical TypeScript API, devDependencies are frequently larger than production dependencies.USER node. Containers run as root by default. There's no reason your Node process needs to be root, and it costs you one line not to be.ENV NODE_ENV=production— some libraries check this at runtime and skip development-only work.
Fix three: pick a smaller base image
Rough sizes for the base image alone, before your code:
| Base image | Approx. size | When I'd use it |
|---|---|---|
node:20 |
~1.1GB | Almost never for production |
node:20-slim |
~240MB | Native modules that fight with musl |
node:20-alpine |
~135MB | My default |
distroless/nodejs20 |
~150MB | Locked-down environments, no shell |
Alpine is my default, but I want to be honest about the trade-off, because "just use Alpine" advice usually skips it.
Alpine uses musl libc instead of glibc. Most things don't care. Some things care a lot — native modules that ship prebuilt glibc binaries have to compile from source instead, which makes builds slower and occasionally breaks outright. sharp, canvas, some database drivers, and anything wrapping a C library are the usual suspects. If you hit that wall, node:20-slim is the sensible retreat: still a fraction of the full image, still glibc.
And distroless is worth knowing about even if you don't use it. It has no shell, no package manager, nothing but the runtime. Great security posture, genuinely unpleasant to debug — you can't docker exec into a shell, because there isn't one.
What the numbers actually look like
Ballpark figures for a typical TypeScript Express or Nest API — your mileage will vary with your dependency tree, but the shape holds:
| Version | Approx. size |
|---|---|
node:20, no .dockerignore, dev deps included |
~1.2GB |
node:20-slim, multi-stage, prod deps only |
~380MB |
node:20-alpine, multi-stage, prod deps only |
~180MB |
Which brings me to a claim I want to push back on. You'll see posts promising sub-50MB Node images. Be sceptical. The Node runtime itself is roughly 120MB on Alpine — you cannot get under that and still run Node. If someone's showing you 40MB, they've either switched to a compiled runtime like Bun or Go, or they're quoting the compressed registry size rather than the on-disk size. Both are fine things to do. Neither is the same claim.
180MB is a realistic, honest target for a Node service. Chasing lower usually means fighting your runtime rather than fixing your image.
The layer-order trick that speeds up every build
One more thing, and it's about time rather than size, but it's the change I appreciate most day to day.
Docker caches each instruction as a layer. When one layer changes, every layer after it rebuilds. So this ordering is quietly expensive:
COPY . .
RUN npm ci # ← re-runs on EVERY code change
And this one isn't:
COPY package*.json ./
RUN npm ci # ← only re-runs when dependencies change
COPY . .
Same instructions, different order. In the second version, editing a controller doesn't reinstall your dependencies — Docker reuses the cached layer and skips straight to copying your source. On a large dependency tree, that's the difference between a two-minute CI build and a fifteen-second one.
The rule of thumb: put the things that change least at the top of your Dockerfile, and the things that change most at the bottom. Your dependencies change weekly. Your source changes hourly. Order accordingly.
My personal take
Image size is one of those problems that feels cosmetic until it isn't.
A 1.2GB image is slower to push, slower to pull, slower to cold-start on every autoscaling event, more expensive to store across every tag you keep, and — the part people underrate — it has a much bigger attack surface. Every package in that Debian base is something a scanner can flag and something you're nominally responsible for patching. Shipping a compiler and a package manager into production isn't just wasteful; it's a bigger blast radius if something does get in.
None of the fixes above are clever. Multi-stage builds, a smaller base, a .dockerignore, and sensible layer ordering. It's maybe twenty minutes of work and it's the highest-leverage twenty minutes in most Dockerfiles I've read.
If you take one thing from this: go run docker images right now and look at the number. If it starts with a 1, you already know what to do.
The short version
- Full
node:20is ~1.1GB before your code.node:20-alpineis ~135MB. That's most of your problem. - Multi-stage builds let you compile with the full toolchain and ship only the output.
npm ci --omit=devin the runtime stage — devDependencies are often bigger than prod ones.- A
.dockerignorekeeps.git,node_modulesand.envout, and keeps your build cache alive. - Copy
package*.jsonand install before copying source, so code changes don't reinstall dependencies. - ~180MB is an honest target for a Node service. Sub-50MB claims are usually a different runtime or a compressed size.
Written by the TechKis team — an AI-first engineering studio. techkis.tech

