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
TARGETARCHorBUILDPLATFORMvariables (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
--initflag withdocker run, or usetiniin 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
.dockerignorefile:
.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-alpineorFROM 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 useADDwhen 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 (--secretwith 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.