Skip to main content

Command Palette

Search for a command to run...

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.

Updated
8 min readView as Markdown
Your Docker Image Is 1.2GB. Here's the 180MB Version.
T
TechKis is an AI-first software & engineering studio focused on building modern digital products, scalable systems, and intelligent automation solutions. We help startups, creators, and businesses transform ideas into production-ready software using modern technologies, cloud-native architecture, and practical AI integration from day one. Unlike traditional agencies that treat AI as an afterthought, TechKis is built around the belief that AI should enhance workflows, products, and engineering decisions from the start. Our focus includes custom software development, AI-powered applications, SaaS platforms, backend engineering, automation tools, APIs, and scalable microservices architecture. We believe in clean architecture, fast execution, maintainable systems, and modern engineering practices that help businesses grow confidently. At TechKis, we combine engineering, design thinking, and AI-first development to create software that is practical, modern, and built for the future. Visit us at https://techkis.tech

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:20 is 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 install installs 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 local node_modules, .env files, screenshots, logs, coverage reports.
  • The build tooling stays in the final image. You needed TypeScript to produce dist/. You do not need TypeScript to run dist/. But it's still in there.
A 1.2GB image broken down into layers: a 1.1GB full Debian base, devDependencies like TypeScript, ESLint and Jest, a copied .git directory and local node_modules, and only 2MB of actual application code

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 two-stage Docker build where the builder stage installs all dependencies and compiles TypeScript, then only the dist folder crosses into a lean runtime stage that installs production dependencies alone, with the entire builder discarded

A few details in there that actually matter:

  • npm ci, not npm install. It installs exactly what's in your lockfile, and it's faster. npm install can silently update your lockfile mid-build, which means your CI image and your teammate's image aren't the same thing.
  • --omit=dev in 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.

Two build orders compared: copying source before installing dependencies invalidates the cache on every code change and reinstalls everything, while copying package.json first keeps the install layer cached and only re-copies the changed source

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:20 is ~1.1GB before your code. node:20-alpine is ~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=dev in the runtime stage — devDependencies are often bigger than prod ones.
  • A .dockerignore keeps .git, node_modules and .env out, and keeps your build cache alive.
  • Copy package*.json and 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