Dockerfile: From 1.2GB to 80MB
A naive Dockerfile for a Node.js app produces a 1.2GB image. A well-written one produces 80MB. The difference is understanding how Docker layers work and applying a few patterns consistently.
The Naive Dockerfile
# DON'T do this
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
RUN npm run build
EXPOSE 3000
CMD ["npm", "start"]Problems: full Node.js image (1GB+), copies node_modules if they exist locally, installs devDependencies, no layer caching, runs as root.
The Production Dockerfile
# Stage 1: Install dependencies
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
# Stage 2: Build
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
# Stage 3: Production image
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
# Don't run as root
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 appuser
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/dist ./dist
COPY --from=builder /app/package.json ./
USER appuser
EXPOSE 3000
CMD ["node", "dist/index.js"]Key Optimizations Explained
| Technique | Impact | Why |
|---|---|---|
node:20-alpine | ~900MB smaller | Alpine Linux is ~5MB vs Debian's ~120MB |
| Multi-stage build | No build tools in final image | Only production artifacts are copied to the final stage |
npm ci vs npm install | Deterministic builds | Uses lockfile exactly, faster, no surprises |
| Copy package.json first | Better layer caching | Dependencies only reinstall when package.json changes |
USER appuser | Security | Container doesn't run as root |
--only=production | Smaller node_modules | No devDependencies in production |
Layer Caching: Order Matters
Docker caches each layer. When a layer changes, all subsequent layers are rebuilt. So put things that change rarely at the top:
# Rarely changes → cache hit
FROM node:20-alpine
WORKDIR /app
# Changes when dependencies change
COPY package.json package-lock.json ./
RUN npm ci
# Changes on every code change → invalidates only this layer
COPY . ..dockerignore
Just as important as .gitignore:
node_modules
.git
.env
*.md
Dockerfile
docker-compose.yml
.next
coverage
tests
.vscode
.DS_StoreWithout this, COPY . . sends your entire node_modules (and git history) to the Docker daemon, making builds slow even before they start.
Python Dockerfile
# Multi-stage Python build
FROM python:3.12-slim AS builder
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir --prefix=/install -r requirements.txt
FROM python:3.12-slim
WORKDIR /app
COPY --from=builder /install /usr/local
COPY . .
RUN useradd --create-home appuser
USER appuser
EXPOSE 8000
CMD ["gunicorn", "app:app", "--bind", "0.0.0.0:8000"]Go Dockerfile (Tiny Final Image)
# Go compiles to a static binary — final image can be scratch
FROM golang:1.22-alpine AS builder
WORKDIR /app
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /app/server .
FROM scratch
COPY --from=builder /app/server /server
EXPOSE 8080
ENTRYPOINT ["/server"]
# Final image: ~10-20MBSecurity Checklist
- Never run as root — always create and use a non-root user
- Don't store secrets in the image — use environment variables or secret mounts
- Pin image versions —
node:20.11.1-alpinenotnode:latest - Scan for vulnerabilities —
docker scout quickviewortrivy image myapp - Use
.dockerignoreto exclude.env,.git, and sensitive files
Docker reference: Docker Cheatsheet — all the commands you need, categorized and searchable.