PureTools

Dockerfile Best Practices: Smaller, Faster Builds

PureTools Team· 9 min read
Dockerfile Best Practices: Smaller, Faster Builds

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

TechniqueImpactWhy
node:20-alpine~900MB smallerAlpine Linux is ~5MB vs Debian's ~120MB
Multi-stage buildNo build tools in final imageOnly production artifacts are copied to the final stage
npm ci vs npm installDeterministic buildsUses lockfile exactly, faster, no surprises
Copy package.json firstBetter layer cachingDependencies only reinstall when package.json changes
USER appuserSecurityContainer doesn't run as root
--only=productionSmaller node_modulesNo 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_Store

Without 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-20MB

Security 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-alpine not node:latest
  • Scan for vulnerabilities — docker scout quickview or trivy image myapp
  • Use .dockerignore to exclude .env, .git, and sensitive files

Docker reference: Docker Cheatsheet — all the commands you need, categorized and searchable.

Dockerfile Best Practices: Smaller, Faster Builds — PureTools — PureTools