# Your Docker Image Is 1.2GB. Here's the 180MB Version.

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.

```dockerfile
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](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/8c15c4c8-c80e-466d-9644-a96067714520.svg align="center")

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.

```dockerfile
# ---- 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](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/d42a6392-1cd3-4e59-b98a-4f0d3956116f.svg align="center")

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:

```dockerfile
COPY . .
RUN npm ci        # ← re-runs on EVERY code change
```

And this one isn't:

```dockerfile
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](https://cdn.hashnode.com/uploads/covers/6a166d51da253d50d4118b39/74055e68-29c1-4fe4-8467-96ff8de4af8a.svg align="center")

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](https://techkis.tech)*
