Skip to main content

Dockerfile Best Practices — 12 Rules for Production-Ready Images

· 7 min read
Goel Academy
DevOps & Cloud Learning Hub

Anyone can write a Dockerfile that works. FROM ubuntu, RUN apt-get install everything, COPY . ., done. It builds. It runs. And it is a 1.8 GB security nightmare that takes 10 minutes to deploy. Here are 12 rules that separate a working Dockerfile from a production-ready one.

Rule 1: Use Specific Base Image Tags

Never use latest. It is a moving target. Your image builds fine today and breaks tomorrow when the base image updates.

# BAD — 'latest' can change any time
FROM node:latest

# BAD — major version can include breaking changes
FROM node:20

# GOOD — pinned to specific version
FROM node:20.11-alpine3.19

Pin to a specific minor or patch version. When you want to upgrade, do it intentionally with a tested change, not as a surprise at 2 AM.

Rule 2: Run as a Non-Root User

By default, containers run as root. If an attacker exploits your application, they have root access inside the container — and potentially to the host via privilege escalation.

FROM node:20-alpine

WORKDIR /app

# Create a non-root user
RUN addgroup -S appgroup && adduser -S appuser -G appgroup

COPY --chown=appuser:appgroup package*.json ./
RUN npm ci --only=production

COPY --chown=appuser:appgroup . .

# Switch to non-root before CMD
USER appuser

CMD ["node", "server.js"]

Rule 3: Use COPY, Not ADD

ADD has magic behavior — it auto-extracts tar files and can download URLs. This is rarely what you want and can introduce unexpected behavior.

# BAD — ADD has implicit behavior
ADD app.tar.gz /app/
ADD https://example.com/config.json /app/

# GOOD — COPY is explicit and predictable
COPY app/ /app/
RUN curl -o /app/config.json https://example.com/config.json

Use COPY for files. If you need to download something, use RUN curl or RUN wget so the intent is clear.

Rule 4: Minimize Layers

Each RUN instruction creates a layer. Combine related commands to reduce the number of layers and keep the image smaller.

# BAD — 3 layers, apt cache stored in first layer
RUN apt-get update
RUN apt-get install -y curl
RUN rm -rf /var/lib/apt/lists/*

# GOOD — 1 layer, cache cleaned in same layer
RUN apt-get update && \
apt-get install -y --no-install-recommends curl && \
rm -rf /var/lib/apt/lists/*

Why does cleaning up in the same layer matter? Because Docker layers are additive. If you install 200 MB of packages in layer 1 and delete them in layer 2, the image is still 200 MB larger. The deletion is just an empty marker in the new layer.

Rule 5: Order Instructions for Caching

Place instructions that change infrequently at the top. Dependencies change less often than application code.

FROM python:3.12-slim

# System dependencies (rarely change)
RUN apt-get update && apt-get install -y --no-install-recommends \
gcc libpq-dev && rm -rf /var/lib/apt/lists/*

# Python dependencies (change occasionally)
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

# Application code (changes frequently)
COPY . .

CMD ["gunicorn", "app:app"]

Rule 6: Use .dockerignore

Without .dockerignore, your build context includes everything — .git history, node_modules, test fixtures, IDE configs.

# .dockerignore
.git
.gitignore
node_modules/
npm-debug.log
Dockerfile
docker-compose*.yml
.env
.env.*
tests/
coverage/
*.md
.vscode/
.idea/
__pycache__/
*.pyc
# Check the difference
# Without .dockerignore: "Sending build context: 847MB"
# With .dockerignore: "Sending build context: 2.1MB"

Rule 7: Never Store Secrets in Images

Secrets baked into images are extractable by anyone who has access to the image. Even if you delete them in a later layer, they exist in the image history.

# BAD — secret is in the layer history forever
COPY .env /app/.env
RUN source /app/.env && npm run build
RUN rm /app/.env # Too late, it's in a previous layer

# GOOD — use build arguments for build-time secrets
ARG NPM_TOKEN
RUN echo "//registry.npmjs.org/:_authToken=${NPM_TOKEN}" > .npmrc && \
npm ci && \
rm .npmrc

# BETTER — use Docker BuildKit secrets (never written to a layer)
# syntax=docker/dockerfile:1
RUN --mount=type=secret,id=npm_token \
NPM_TOKEN=$(cat /run/secrets/npm_token) npm ci
# Build with BuildKit secret
DOCKER_BUILDKIT=1 docker build --secret id=npm_token,src=.npm_token -t myapp .

Rule 8: Use HEALTHCHECK

A running container is not necessarily a healthy container. Your process might be alive but deadlocked, or the app might have crashed while the shell keeps running.

FROM node:20-alpine
WORKDIR /app
COPY . .
RUN npm ci --only=production

HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD wget --no-verbose --tries=1 --spider http://localhost:3000/health || exit 1

CMD ["node", "server.js"]

Docker, Compose, and orchestrators use healthcheck results to determine whether to route traffic to the container or restart it.

Rule 9: Understand ENTRYPOINT vs CMD

InstructionPurposeOverridable?
ENTRYPOINTThe executable that always runsOnly with --entrypoint
CMDDefault arguments to ENTRYPOINTEasily overridden at runtime
# Pattern 1: CMD only (fully overridable)
CMD ["python", "app.py"]
# docker run myapp python manage.py migrate (replaces entire CMD)

# Pattern 2: ENTRYPOINT + CMD (structured defaults)
ENTRYPOINT ["python"]
CMD ["app.py"]
# docker run myapp manage.py migrate (keeps python, replaces app.py)

# Pattern 3: Shell script entrypoint (for initialization)
COPY entrypoint.sh /entrypoint.sh
RUN chmod +x /entrypoint.sh
ENTRYPOINT ["/entrypoint.sh"]
CMD ["start"]

Use the exec form (["executable", "arg"]) not the shell form (executable arg) so that signals like SIGTERM reach your process directly.

Rule 10: Pin Versions in apt-get

Unpinned packages mean your build is not reproducible. What builds today might fail or behave differently next month.

# BAD — version could change
RUN apt-get update && apt-get install -y curl

# GOOD — pinned version
RUN apt-get update && apt-get install -y \
curl=7.88.1-10+deb12u5 \
&& rm -rf /var/lib/apt/lists/*
# Find available versions
docker run --rm debian:bookworm-slim apt-cache policy curl

At minimum, use --no-install-recommends to avoid pulling in unnecessary suggested packages.

Rule 11: Clean Up in the Same Layer

Deletions in a new layer do not reduce image size. The data still exists in the previous layer.

# BAD — build tools remain in image (200MB wasted)
RUN apt-get update && apt-get install -y gcc make
RUN make build
RUN apt-get remove -y gcc make # This doesn't reclaim space!

# GOOD — install, use, and clean up in one layer
RUN apt-get update && \
apt-get install -y --no-install-recommends gcc make && \
make build && \
apt-get purge -y gcc make && \
apt-get autoremove -y && \
rm -rf /var/lib/apt/lists/*

Rule 12: Use Multi-Stage Builds

Multi-stage builds are the most impactful optimization. Build in one stage, copy only the artifacts you need into a minimal final stage.

# Stage 1: Build (large image with all build tools)
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 .

# Stage 2: Runtime (tiny image, no build tools)
FROM gcr.io/distroless/static-debian12
COPY --from=builder /app/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]
docker build -t myapp .
docker images myapp
# REPOSITORY TAG SIZE
# myapp latest 8.2MB (vs 300MB+ without multi-stage)

Before and After: Complete Example

Here is a real transformation — the same Node.js app, amateur vs production.

# BEFORE: 1.1 GB, runs as root, no health check, poor caching
FROM node:20
WORKDIR /app
COPY . .
RUN npm install
EXPOSE 3000
CMD npm start
# AFTER: 165 MB, non-root, health checked, optimized caching
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build && npm prune --production

FROM node:20-alpine
RUN addgroup -S app && adduser -S app -G app
WORKDIR /app
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --from=builder --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/package.json ./
USER app
HEALTHCHECK --interval=30s --timeout=5s --retries=3 \
CMD wget --spider -q http://localhost:3000/health || exit 1
EXPOSE 3000
CMD ["node", "dist/server.js"]

Wrapping Up

These 12 rules are not academic theory — they directly impact your build times, image sizes, security posture, and deployment reliability. Start with the low-hanging fruit (.dockerignore, layer ordering, non-root user) and work your way up to multi-stage builds.

In the next post, we will cover Docker Registries — Docker Hub, ECR, ACR, GHCR, and how to choose the right registry for your team, with real setup examples and a comparison table.