Dockerfile Best Practices

Dockerfile Best Practices

Bitnesia Sep 14, 2026 13 ID

Writing a Dockerfile that can be built is not difficult, but writing a Dockerfile that is efficient, secure, fast to rebuild, and easy for other team members to understand is a skill that continuously develops through experience. This chapter summarizes advanced habits commonly maintained by experienced Sysadmins/DevOps Engineers and Developers once a basic Dockerfile is running smoothly: instructions that make containers easier to monitor, security practices when writing instructions, BuildKit techniques to speed up recurring builds, and ways to document a Dockerfile so others (or ourselves months later) do not have to guess the reasoning behind a line of instruction.

42.1 Image Efficiency with HEALTHCHECK and COPY --link

The efficiency of a Dockerfile is not just about image size; proper instructions also make Docker more efficient at monitoring container health and more precise in reusing cache during subsequent builds. The following two instructions are often overlooked despite their significant impact on daily operations.

42.1.1 HEALTHCHECK for Detecting Problematic Containers

HEALTHCHECK tells Docker how to check whether the process inside the container is still functioning properly, rather than merely running. A web server's status might remain running according to docker ps even though the internal process is stuck in an infinite loop and no longer responding to new connections; HEALTHCHECK catches this condition through a separate health status that starts at starting, changes to healthy after a successful check, and turns to unhealthy after a set number of consecutive failures.

FROM nginx:1.27-alpine
COPY index.html /usr/share/nginx/html/index.html
HEALTHCHECK --interval=30s --timeout=3s --start-period=5s --retries=3 \
  CMD curl -f http://localhost/ || exit 1

The --interval option sets the delay between checks (default 30 seconds), --timeout limits the maximum duration for a single check before it is considered failed (default 30 seconds), --start-period provides initialization time at startup so failures during initial container launch are not immediately counted as failed attempts (default 0 seconds), and --retries specifies the number of consecutive failures required before the status changes to unhealthy (default 3 times). Build and run this container, then verify its health status.

docker build -t nginx-healthcheck:1.0 .
docker run -d -p 8080:80 --name demo-health nginx-healthcheck:1.0
docker inspect --format='{{.State.Health.Status}}' demo-health

The exit code from the check command determines the status: 0 means healthy, 1 means unhealthy, while code 2 is reserved and should not be used. In practice, orchestrators like Docker Swarm as well as load balancers in front of containers utilize this status to determine whether traffic can be routed to the container, ensuring that unhealthy containers are automatically removed from rotation without requiring manual intervention. Clean up the test container when finished.

docker rm -f demo-health

The STOPSIGNAL instruction is also worth paying attention to alongside HEALTHCHECK, as both deal with the container lifecycle. By default, Docker sends a SIGTERM signal when docker stop is invoked, giving the main process an opportunity to shut down gracefully before being forcefully terminated via SIGKILL after a specified timeout expires. If the application inside the image requires a different signal for a clean shutdown, for example, certain Nginx-based applications that respond to SIGQUIT for graceful shutdown, set it using STOPSIGNAL.

FROM nginx:1.27-alpine
STOPSIGNAL SIGQUIT

STOPSIGNAL only affects the signal sent by docker stop or when the Docker daemon stops a container, and does not apply to Ctrl+C in an interactive terminal, which always sends SIGINT directly to the process.

42.1.2 COPY --link for More Precise Layer Caching

By default, every layer resulting from COPY is tightly bound to previous layers, meaning changes to instructions before COPY, such as updating the base image version, invalidate the cache for that COPY layer even if the copied file content has not changed at all. The BuildKit flag --link solves this by copying files into an independent empty directory and linking the result as a separate layer on top of the previous state, preventing its cache from being invalidated just because lower layers changed.

# syntax=docker/dockerfile:1
FROM alpine:3.20
COPY --link app/ /opt/app/

The benefit of --link becomes even more evident when updating base image tags without changing source code, such as updating an Alpine patch version due to a patched CVE. Without --link, all COPY layers following FROM must be re-executed even if the copied content is identical; with --link, those COPY layers can be reused from cache because their status is independent of the underlying base image. This feature requires the syntax directive syntax=docker/dockerfile:1 on the first line of the Dockerfile so BuildKit recognizes the syntax.

42.2 Security Habits when Writing Dockerfiles

In-depth discussion regarding supply chain security, scanning, and selecting minimal base images has been covered in the image security chapter. This section complements that by addressing two specific habits when writing Dockerfile instructions that directly impact build security: pinning base images down to their digest, and validating the integrity of resources downloaded via ADD.

42.2.1 Pin Base Images to Digest

Image tags such as node:20-alpine can point to different content over time, because image maintainers may re-publish the same tag with updated content, such as after the underlying base OS receives security patches. For strictly reproducible build requirements, pin the FROM instruction down to its SHA-256 digest, which is unique and permanent for a specific image payload.

docker pull node:20-alpine
docker inspect --format='{{index .RepoDigests 0}}' node:20-alpine

The command above displays the full digest of the newly pulled image in the format node@sha256:.... Paste that digest into the FROM instruction.

FROM node@sha256:1a2b3c4d5e6f7890abcdef1234567890abcdef1234567890abcdef1234567890

Consequently, builds will no longer automatically follow base image updates; the digest must be updated manually (or via automation like Dependabot/Renovate) whenever pulling the latest content from the same tag is desired. In practice, Sysadmins/DevOps Engineers aiming for high reproducibility in CI/CD pipelines typically accept this trade-off to guarantee that the exact same image will always be produced from the same Dockerfile, avoiding unexpected changes from base image updates outside the team's control.

42.2.2 Validating Resource Integrity with ADD --checksum

When a Dockerfile needs to download artifacts from the external internet, such as installers or binary releases from third-party repositories, using the ADD instruction with the --checksum option is recommended over running manual wget or curl commands via RUN. Beyond providing more precise build caching because BuildKit knows exactly when the resource changes, --checksum verifies the SHA-256 hash of the downloaded file and immediately fails the build if a mismatch occurs, preventing tampered or silently swapped artifacts from being included in the image.

# syntax=docker/dockerfile:1
FROM alpine:3.20
ADD --checksum=sha256:5891b5b522d5df086d0ff0b110fbd9d21bb4fc7163af34d08286a2e846f6be0 \
    https://example.com/rilis/app-1.0.0.tar.gz /tmp/app.tar.gz
RUN tar -xzf /tmp/app.tar.gz -C /opt/ && rm /tmp/app.tar.gz

If the digest specified in --checksum does not match the downloaded result, Docker stops the build with a checksum mismatch error message instead of silently proceeding with the wrong file. Obtain official checksums from the resource publisher's release page rather than calculating them locally from downloaded files without source verification, as the primary purpose of --checksum is ensuring the received file matches what the publisher produced. The scenario targeted here is real: an attacker who successfully compromises artifact distribution channels, such as through a compromised mirror registry or a man-in-the-middle attack on a CI/CD network, could insert malicious binaries that appear identical in filename and size. Without --checksum, such fake artifacts would seamlessly slip into the image undetected.

42.2.3 Dockerfile Review Checklist Before Merge

Before merging Dockerfile changes into the main branch, the following points serve as useful review items, whether conducted manually in pull requests or automatically via linters like Hadolint in CI/CD pipelines.

Review PointReason
Base image uses specific version tag or digest, not latestBuilds remain predictable and reproducible
No credentials or API keys hardcoded via ENV, ARG, or COPYSensitive data is not permanently stored in image layers
Non-root USER instruction placed before CMD/ENTRYPOINTLimits damage in the event of a container escape
HEALTHCHECK defined for applications serving trafficEnables automatic detection of stuck containers
External resources via ADD use --checksumPrevents tampered artifacts from entering the image

42.3 Build Optimization with BuildKit Cache

Docker's default layer cache helps speed up rebuilds, but this cache is completely invalidated whenever instructions prior to a RUN change, including when a single new dependency is added to package.json. BuildKit provides additional cache mechanisms that persist across builds despite changes to prior instructions, which can also be shared across machines via a registry.

42.3.1 RUN --mount=type=cache for Package Managers

Cache mount mounts a temporary directory whose contents persist across builder invocations, specifically designed for compiler and package manager cache directories like npm, pip, or apt. Unlike regular layers, contents inside a cache mount directory are never saved as part of the final image layer; the directory is only available while the corresponding RUN instruction executes.

# syntax=docker/dockerfile:1
FROM node:20-alpine
WORKDIR /app
COPY package.json package-lock.json ./
RUN --mount=type=cache,target=/root/.npm \
    npm ci

The initial build downloads all dependencies as usual, but subsequent builds, even when normal layer caches are invalidated due to changes in package-lock.json, can reuse previously downloaded packages from the cache mount, ensuring that only new or updated packages need to be downloaded again. A similar pattern applies to apt-get on Debian/Ubuntu base images, using the sharing=locked option so parallel builds do not contend for access to the same cache directory.

# syntax=docker/dockerfile:1
FROM ubuntu:24.04
RUN rm -f /etc/apt/apt.conf.d/docker-clean; \
    echo 'Binary::apt::APT::Keep-Downloaded-Packages "true";' > /etc/apt/apt.conf.d/keep-cache
RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
    --mount=type=cache,target=/var/lib/apt,sharing=locked \
    apt-get update && apt-get install -y --no-install-recommends curl

The line rm -f /etc/apt/apt.conf.d/docker-clean is necessary because default Ubuntu/Debian base images configure apt to automatically remove downloaded .deb files after installation finishes, a behavior that directly conflicts with the goal of cache mounts to retain files across builds. The id option in --mount=type=cache is useful when separate caches are required for different needs within the same Dockerfile, such as distinct pip caches across stages.

42.3.2 Registry Build Cache with Buildx

The cache mount above only resides locally on the host builder executing it, offering limited help when builds run on clean, ephemeral CI/CD runners. Buildx, Docker's official CLI plugin for advanced builds, provides --cache-to and --cache-from options to export and import build caches via a container registry, enabling shared cache usage across machines and CI/CD jobs.

docker buildx build --push -t registry.example.com/app:1.0 \
  --cache-to type=registry,ref=registry.example.com/app:buildcache \
  --cache-from type=registry,ref=registry.example.com/app:buildcache .

Every cache storage backend, including registry, must be explicitly exported using --cache-to and imported using --cache-from; unlike local BuildKit caching, which is active by default without configuration. If a --cache-from target does not exist yet, such as during an initial build, the cache import step fails silently without interrupting the build process, allowing the build to proceed without cache. To import cache from multiple sources, such as combining cache from the current branch with cache from the main branch, specify --cache-from multiple times.

docker buildx build --push -t registry.example.com/app:1.0 \
  --cache-to type=registry,ref=registry.example.com/app:buildcache-feature \
  --cache-from type=registry,ref=registry.example.com/app:buildcache-feature \
  --cache-from type=registry,ref=registry.example.com/app:buildcache-main .

Note that a single cache location should not be concurrently written to twice without overwriting previous data; to maintain separate caches per Git branch, ensure each branch uses a distinct cache reference as shown in the example above. Buildx also supports multi-platform builds simultaneously via the --platform option, useful when the same image needs to run on both linux/amd64 runners and linux/arm64 devices like Apple Silicon or ARM servers.

docker buildx build --platform linux/amd64,linux/arm64 \
  -t registry.example.com/app:1.0 --push .

Multi-platform builds require a Buildx builder instance capable of cross-architecture execution, typically via QEMU emulation, and can take significantly longer than single-platform builds because each target architecture is built separately before being merged into a single manifest list.

42.4 Dockerfile Documentation

An efficient and secure Dockerfile remains difficult to maintain if no one understands the reasoning behind specific decisions, such as why a particular base image was selected or why a RUN instruction was written in an unusual way. Good documentation saves team time down the road, proving much cheaper than re-guessing lost context.

42.4.1 LABEL and OCI Image Annotations

The LABEL instruction adds permanent metadata to an image as key-value pairs, which can be inspected anytime via docker image inspect without opening the original Dockerfile. The Open Container Initiative (OCI) defines a standard set of LABEL keys prefixed with org.opencontainers.image.* widely recognized across the container ecosystem, replacing the deprecated MAINTAINER instruction in official Dockerfile reference docs.

FROM node:20-alpine
LABEL org.opencontainers.image.title="Payment Service"
LABEL org.opencontainers.image.version="1.4.2"
LABEL org.opencontainers.image.authors="[email protected]"
LABEL org.opencontainers.image.source="https://github.com/contoh/payment-service"
LABEL org.opencontainers.image.description="Internal payment processing service"
LABEL org.opencontainers.image.licenses="MIT"

The org.opencontainers.image.source key is particularly useful for tracking an image back to its source repository, while org.opencontainers.image.revision can be populated automatically with the Git commit hash at build time via ARG, ensuring every image can be traced back to its precise commit.

FROM node:20-alpine
ARG GIT_REVISION=unknown
LABEL org.opencontainers.image.revision=$GIT_REVISION
docker build --build-arg GIT_REVISION=$(git rev-parse HEAD) -t app:1.0 .

Verify labels embedded in the image using docker image inspect, filtering specifically for the Labels section for cleaner output.

docker image inspect app:1.0 --format='{{json .Config.Labels}}'

42.4.2 Comments and Companion README Files

Comments inside a Dockerfile begin with a hash sign (#) at the start of a line and are most valuable for explaining the reason (why) behind a decision that is not self-evident from reading the instruction itself, rather than repeating what the instruction clearly states.

# syntax=docker/dockerfile:1

# Base image is pinned to version 20 LTS; do not upgrade to a new major version
# without testing native dependency compatibility in package.json.
FROM node:20-alpine

WORKDIR /app
COPY package.json package-lock.json ./

# --omit=dev because devDependencies (test runner, linter) are not required
# in the production image and only inflate image size without runtime benefit.
RUN npm ci --omit=dev

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

For projects with complex Dockerfile setups, such as those utilizing multiple build arguments or multiple Dockerfile variants for different environments, additional documentation in a README.md file alongside the Dockerfile helps new developers understand usage without dissecting file contents line by line. This companion README should cover available build arguments along with default values and purposes, complete docker build command examples with common options, and brief notes regarding exposed ports and expected volume mounts during container execution. Such documentation also streamlines onboarding for new developers interacting with the project for the first time, eliminating the need to ask teammates directly just to learn how to build the image correctly.