Base image & preparation

Use a minimal base image

Prefer the smallest base image that provides everything your application needs.

ImageApprox. sizeUse case
Alpine~5 MBSmall general-purpose runtime
Slim~50 MBSmaller Debian-based runtime
DistrolessMinimalProduction runtime without shell/tools
Scratch0 MBStatically linked binaries

A smaller image has less surface area for something to go wrong.

Pin versions — never use :latest

The latest tag is mutable. It can point to a different image tomorrow, and your build stops being reproducible.

For maximum reproducibility, pin the image digest as well:

FROM python:3.11.4-slim@sha256:...

Use a .dockerignore file

Exclude unnecessary and sensitive files from the build context: .git, node_modules, .env, logs, local configuration, build artifacts.

This reduces the build context and helps prevent sensitive files from accidentally being copied into the image.

.git
node_modules
.env
*.log

Build optimization

Optimize layer order & caching

Docker caches layers. If a layer changes, all subsequent layers need to be rebuilt.

Put rarely changing things first. Put frequently changing code last.

This allows Docker to reuse the dependency layer when only application code changes.

Combine RUN commands & clean caches

Each RUN instruction creates a new layer. Deleting a file in a later layer does not remove it from previous layers.

Create → use → clean up in the same layer.

Prefer COPY over ADD

ADD has additional behavior, such as extracting local tar archives and fetching remote URLs. COPY is simple and predictable.

If you need to download something, do it explicitly:

RUN curl -fsSL https://example.com/file.tar.gz \
    | tar -xz -C /app

Use ADD only when you specifically need its additional features.

Use multi-stage builds

Build the application in a full development image and run it in a minimal runtime image. Build environment ≠ runtime environment.

The final image contains only the application and its runtime dependencies.

Use BuildKit

BuildKit is Docker’s modern build engine — better caching, parallel builds, and support for cache and secret mounts.

Modern Docker versions use BuildKit by default. For older installations:

export DOCKER_BUILDKIT=1

docker build .

Don’t run as root

Running an application as root inside a container increases the potential impact of a container compromise. Follow the principle of least privilege.

Use build secrets — not ARG / ENV

Do not pass secrets through ARG or ENV. Both end up baked into image metadata and build history, so anyone with access to the image can pull them back out.

The secret is mounted only for the duration of the build step and is not copied into the resulting image.

Stability

PID 1 & graceful shutdown

The first process inside a container becomes PID 1 and has special responsibilities, including signal handling. A shell used as the main process may not properly forward signals to the application.

A shell as PID 1 swallows SIGTERM, forcing a slow SIGKILLKubernetesSIGTERMShellsignal not forwardedApplication keeps runninggrace period expiresSIGKILL
A shell as PID 1 swallows SIGTERM, forcing a slow SIGKILL

Use exec in shell entrypoints:

exec node app.js

This replaces the shell process with the application. Alternatively, use a minimal init system such as Tini:

ENTRYPOINT ["/sbin/tini", "--"]

Tini handles signal forwarding and reaps zombie processes.

Use HEALTHCHECK

A running process does not necessarily mean that the application is healthy.

A running process can still be unresponsiveProcess is runningHTTP server is stuckDocker still reports "running"
A running process can still be unresponsive

Add a health check:

HEALTHCHECK \
    --interval=30s \
    --timeout=3s \
    CMD curl -f http://localhost:8080/health || exit 1
OptionMeaning
--interval=30sCheck every 30 seconds
--timeout=3sFail after 3 seconds
exit 1Report unhealthy

Make sure curl or another health-check tool is available in the image.

Use Hadolint

Hadolint is a static analyzer for Dockerfiles. It catches the stuff from this guide automatically: bad layer order, missing cleanup, :latest tags, unnecessary packages.

Run locally:

hadolint Dockerfile

Run in CI:

docker run --rm -i hadolint/hadolint < Dockerfile

Complete production Dockerfile

Here’s a production-ready example for a Node.js + TypeScript app, putting everything above into one Dockerfile: multi-stage build, non-root user, health check, and dumb-init for signal handling.

Dockerfile
# syntax=docker/dockerfile:1.7

ARG NODE_VERSION=22.14.0

# Stage 1: Dependencies
FROM node:${NODE_VERSION}-alpine AS deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
  npm ci

# Stage 2: Build
FROM node:${NODE_VERSION}-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY package*.json ./
COPY tsconfig.json ./
COPY src ./src
RUN npm run build

# Stage 3: Production dependencies
FROM node:${NODE_VERSION}-alpine AS prod-deps
WORKDIR /app
COPY package*.json ./
RUN --mount=type=cache,target=/root/.npm \
  npm ci --omit=dev \
  && npm cache clean --force

# Stage 4: Production
FROM node:${NODE_VERSION}-alpine AS production
WORKDIR /app

# Install a minimal init system for proper signal handling
RUN apk add --no-cache dumb-init

# Create a non-root user
RUN addgroup -S app \
  && adduser -S app -G app

# Copy only what is required at runtime
COPY --from=prod-deps --chown=app:app /app/node_modules ./node_modules
COPY --from=builder --chown=app:app /app/dist ./dist
COPY --chown=app:app package*.json ./

USER app

ENV NODE_ENV=production

EXPOSE 3000

# Container health check
HEALTHCHECK \
  --interval=30s \
  --timeout=3s \
  --start-period=10s \
  --retries=3 \
  CMD node -e \
  "fetch('http://127.0.0.1:3000/health').then(r => { if (!r.ok) process.exit(1) }).catch(() => process.exit(1))"

ENTRYPOINT ["dumb-init", "--"]

CMD ["node", "dist/main.js"]

.dockerignore
.git
.gitignore

node_modules
npm-debug.log*

.env
.env.*
!.env.example

dist
coverage

Dockerfile
docker-compose*.yml

*.log
.DS_Store

Build and run:

docker build -t my-app:1.0.0 .

docker run \
    --rm \
    -p 3000:3000 \
    my-app:1.0.0

What makes this production-oriented?

Four stages: build with everything, run with only what you needDependenciesnpm ci + cacheBuilderTypeScript → JSProduction depsnpm --omit=devProductionminimal, non-root, dumb-init
Four stages: build with everything, run with only what you need

Key principle: build with everything you need, run with only what you need. Build environment ≠ production environment.

Quick reference

RulePrefer
Base imageMinimal image
Image tagsPinned versions / digests
Build context.dockerignore
Layer orderRarely changing → frequently changing
File copyingCOPY
Production imageMulti-stage build
Build engineBuildKit
UserNon-root
SecretsBuildKit secrets
Entrypointexec / Tini / dumb-init
Container healthHEALTHCHECK
Dockerfile lintingHadolint

Further reading

For more container best practices, and skills built specifically for AI coding agents working on containerized applications, see the official Docker Skills repository.