← Back to Blog

Dockerfile Checklist — The Checklist I Use When Writing Dockerfiles

If you regularly work with Kubernetes or CI/CD pipelines, a Dockerfile is unavoidable. Writing one is easy — anyone can do it after 5 minutes of Googling — but writing one that's optimized, secure, and lightweight is a different story.

Dockerfile checklist cover

Today I'm putting together the Dockerfile Creation Checklist I usually use to review code for my team. This post covers 3 main areas: Configuration, Performance, and Security. For each point, I'll give a Good and a Bad example so it's easy to picture.

Let's go! ⏩️

Note: The points listed below are things I've gathered from various sources — pick whichever ones make sense for your project. You don't need to apply every single one to your Dockerfile.

Configuration

This section focuses on making your image behave reliably, easy to configure, and compatible across environments.

Support multiple machine architectures (amd64, arm64)

Never hardcode a platform if you want your image to run on both a Macbook M1/M2 (ARM) and a traditional server (AMD).

  • Bad: Hardcoding at build time or pulling a base image that only supports 1 platform without checking.
  • Good: Use the TARGETARCH or BUILDPLATFORM variables (from Docker Buildx) to automatically pick the right binary.
ARG TARGETARCH
COPY bin/app-${TARGETARCH} /app/main

Set an explicit CMD / ENTRYPOINT / run the application as PID 1

Always prefer the exec form ["executable", "param1", "param2"] over the shell form command param1. The shell form wraps your command in /bin/sh -c, meaning your process isn't PID 1 and doesn't receive Unix signals (like SIGTERM for graceful shutdown).

A container should have an init process (PID 1) to reap zombie processes and forward signals.

  • Bad: Running a java/python app directly with no init system handling signals.
  • Good: Use the --init flag with docker run, or use tini in the Dockerfile.
ENV TINI_VERSION v0.19.0
ADD https://github.com/krallin/tini/releases/download/${TINI_VERSION}/tini /tini
RUN chmod +x /tini
ENTRYPOINT ["/tini", "--", "/docker-entrypoint.sh"]

Add a .dockerignore file

Don't send your entire .git folder, node_modules, or local log files into the Docker build context. It slows down the build and needlessly bloats the image.

  • Bad: COPY . . (sweeping the whole world into the image).
  • Good: Have a .dockerignore file:
.git
node_modules
build
*.log
.env

Define the listening port (EXPOSE)

This documents for users which port the container listens on.

  • Bad: Not declaring anything, leaving whoever runs it to guess.
  • Good:
EXPOSE 8080

Define a HEALTHCHECK

Helps Docker (and Kubernetes) know whether your application is still "alive" so it can restart it.

  • Bad: No healthcheck (the container is running but the app inside is hung, and the orchestrator has no idea).
  • Good:
HEALTHCHECK --interval=30s --timeout=3s \
  CMD curl -f http://localhost:8080/health || exit 1

Add labels (org.opencontainers.image.*)

Adding standard OCI metadata makes image management easier.

  • Bad: An "anonymous" image — no one knows who maintains it.
  • Good:
LABEL org.opencontainers.image.authors="Hoang Viet"
LABEL org.opencontainers.image.source="https://github.com/viet111/my-app"

Pin package and dependency versions

Avoid the situation where a build works today and breaks tomorrow because a package auto-updated to a new version.

  • Bad: apt-get install python3
  • Good: apt-get install python3=3.9.1-r0

Reproducible builds: use ARG

Flexibly change a version without hardcoding it in the Dockerfile.

  • Bad: FROM node:14
  • Good:
ARG NODE_VERSION=18
FROM node:${NODE_VERSION}-alpine

Performance

In this section I'll cover points you can apply to get an image that builds fast, is small, and caches nicely.

Use a minimal, official base image

Use compact distributions like Alpine or Distroless.

  • Bad: FROM ubuntu:latest (heavy, contains far too much you don't need).
  • Good: FROM node:18-alpine or FROM gcr.io/distroless/static-debian11.

Use multi-stage builds

This is the single most important technique for reducing image size for compiled languages (Go, Java, C++).

  • Bad: Building and running in the same stage. The image contains source code, the compiler (GCC, Maven...), and the artifact all at once.
  • Good: Stage 1 builds, Stage 2 just copies the binary into an empty image (scratch/alpine).
# Build Stage
FROM golang:1.19 AS builder
WORKDIR /app
COPY . .
RUN go build -o main .

# Run Stage
FROM alpine:latest
COPY --from=builder /app/main .
CMD ["./main"]

Prefer COPY over ADD

ADD can extract tar files and download files from a URL (hard to control caching for). COPY is simpler and more explicit.

  • Bad: ADD . /app
  • Good: COPY . /app (only use ADD when you genuinely need to fetch a remote file or extract a tarball).

Minimize the number of layers

Combine RUN commands together to reduce the number of intermediate layers.

  • Bad:
RUN apt-get update
RUN apt-get install -y vim
RUN apt-get install -y curl
  • Good:
RUN apt-get update && apt-get install -y \
    vim \
    curl \
    && rm -rf /var/lib/apt/lists/*

Order layers for cache efficiency

Order the instructions in your Dockerfile from least-likely-to-change (OS, dependencies) to most-likely-to-change (source code).

  • Bad: Copying source code before installing dependencies. Every time you change the code, Docker has to reinstall the libraries from scratch.
COPY . .
RUN npm install # Loses the cache every time the code changes
  • Good:
COPY package.json .
RUN npm install # Gets cached if package.json hasn't changed
COPY . .

Security

Container security is an inseparable part of the DevSecOps process.

Use a base image pinned by sha256 hash

Tags like :latest or :18-alpine are mutable — their content can change. A sha256 hash is immutable. Pinning to a SHA256 digest protects you against supply-chain attacks.

  • Bad: FROM node:lts-alpine
  • Good: FROM node@sha256:52a6d123... (guarantees the image content is 100% what you expect).

Remove the shell, package manager, and debug tools

An attacker who breaks into your container can't do much without a shell (sh, bash) or curl/wget.

  • Bad: Installing vim, curl, net-tools, and everything else "just in case" for debugging convenience.
  • Good: Use Distroless images (no shell, no package manager).

Run static scans (hadolint, trivy)

Integrate these into the CI/CD pipeline to block a build if the Dockerfile violates a rule or contains CVEs rated HIGH, CRITICAL, etc.

Example: Run trivy image my-app:latest before pushing to the registry.

Use a non-root user

Docker runs as root by default. If an attacker escapes the container (container breakout), they'll have root on the host machine.

  • Bad: Not declaring a USER (defaults to root).
  • Good:
RUN addgroup -S appgroup && adduser -S appuser -G appgroup
USER appuser

Never put secrets or credentials in the Dockerfile

Absolutely never hardcode API keys or passwords. Even if you remove the secret in a later step of the Dockerfile, it still exists in the earlier layer.

  • Bad:
ENV DB_PASSWORD=secret123
COPY id_rsa /root/.ssh/
  • Good: Use environment variables at runtime (docker run -e) or mount a secret (--secret with Docker Buildkit).

Hope this checklist helps you feel more confident writing Dockerfiles. Save it and put it to use on your project right away! If you found this useful, don't forget to upvote and follow me for more upcoming posts on DevOps and Kubernetes.

Happy Coding! 👨‍💻

If you're running into technical challenges, need help with systems, DevOps tools, or career direction, I'm confident I can help. Get in touch at hoangviet.io.vn — I'd love to chat and collaborate with you.

Have thoughts on this?

I'd love to hear your perspective. Send me a message.

Get in touch