A tiny Express + TypeScript API had a 1.79 GB Docker image. After four changes it's 246 MB, rebuilds about 4× faster after a code edit, and doesn't ship TypeScript, Jest or ESLint to production.
All sizes below were measured with docker images on Docker 29.
Video version:
The starting point
The Dockerfile from most tutorials:
FROM node:22 WORKDIR /app COPY . . RUN npm install RUN npm run build CMD ["node", "dist/server.js"] $ docker build -t api . Sending build context to Docker daemon 80.23MB $ docker images api api latest 1.79GB 😱 That's 80 MB of build context for a few lines of code, and a 1.79 GB image.
Step 1: .dockerignore
node_modules .git dist *.log .env COPY . . was sending my local node_modules, the .git folder, and potentially my .env (with secrets!) into the build. With a .dockerignore, the build context dropped from 80.23 MB to 235 KB.
To be honest about the numbers: the image size barely changed at this step, because npm install recreates node_modules anyway. The wins here are faster build starts, no macOS-built binaries leaking into a Linux image, and no secrets in your image layers.
Step 2: a smaller base image (the biggest win)
$ docker images node node 22 1.63GB node 22-slim 349MB node 22-alpine 235MB The default node:22 image is a full Debian system with compilers and build tools. Changing one line to FROM node:22-alpine took the app image from 1.79 GB to 410 MB. node:22-slim gave 522 MB.
Alpine uses musl instead of glibc, which occasionally matters for native modules. If something breaks, -slim is the safe middle ground.
Step 3: a multi-stage build
# stage 1: build with dev dependencies FROM node:22-alpine AS build WORKDIR /app COPY package*.json ./ RUN npm ci COPY . . RUN npm run build # stage 2: only what runs in production FROM node:22-alpine WORKDIR /app ENV NODE_ENV=production COPY package*.json ./ RUN npm ci --omit=dev COPY --from=build /app/dist ./dist USER node CMD ["node", "dist/server.js"] The first stage has everything: TypeScript, Jest, ESLint. It compiles the app. The second stage starts fresh, installs only production dependencies, and copies in just the compiled dist folder. Everything else from stage 1 is thrown away.
$ docker images api api multi-stage 246MB ✅ $ docker run api ls node_modules | wc -l 83 # was 289 $ docker run api ls node_modules/typescript ls: node_modules/typescript: No such file or directory ✅ That's 246 MB, with 83 packages instead of 289, and no dev tools in production. Less to download on every deploy, and less code for an attacker to work with. USER node also means the app doesn't run as root.
Step 4: copy package.json first (layer caching)
Docker caches each step, but once a step's input changes, every step after it reruns:
# ❌ any code change → npm install runs again COPY . . RUN npm install # ✅ code change → npm ci comes from cache COPY package*.json ./ RUN npm ci COPY . . I edited src/server.ts and rebuilt three times with each layout:
COPY . . first: 11.0 s, 9.0 s, 9.9 s package.json first: 3.0 s, 2.3 s, 2.2 s The install only reruns when package.json or package-lock.json actually change.
Cheat sheet
| Step | Result |
|---|---|
.dockerignore (node_modules, .git, .env) | context 80 MB → 235 KB, no leaked secrets |
node:22 → node:22-alpine | 1.79 GB → 410 MB |
Multi-stage + npm ci --omit=dev | 410 MB → 246 MB |
Copy package*.json before code | rebuild ~10 s → ~2.3 s |
USER node | don't run as root |
How big is your biggest image? Run docker images and tell me. 👇
I post one tested coding tip every day on YouTube at Refactorly. The 30-second version of this one is here.