Docker Best Practices

Containerization has become the cornerstone of modern application deployment, enabling teams to build, ship, and run applications consistently across any environment. Docker, as the leading containerization platform, empowers organizations to achieve unprecedented levels of consistency, portability, and efficiency in their software delivery pipelines.

Why Docker Best Practices Matter

Docker images form the foundation upon which your entire containerized infrastructure is built. A poorly constructed image can lead to bloated containers that consume excessive storage and memory, slow deployment times that bottleneck your CI/CD pipelines, and security vulnerabilities that expose your infrastructure to malicious actors.

According to industry research, the majority of container images contain known vulnerabilities, making adherence to security best practices not just a recommendation but a critical requirement for any organization operating in production environments. Beyond security, inefficient image construction can significantly impact your cloud infrastructure costs, as larger images require more storage and longer transfer times across networks.

The practices outlined in this guide address these concerns holistically, providing you with a framework for creating Docker images that are optimized for performance, security, and maintainability. For teams looking to streamline their entire deployment pipeline, integrating these practices with your web development workflow can dramatically reduce time-to-market while improving reliability.

Multi-Stage Builds for Optimal Image Size

One of the most transformative techniques for creating efficient Docker images is the implementation of multi-stage builds. This approach fundamentally changes how you construct container images by allowing you to separate the build environment from the runtime environment, resulting in dramatically smaller final images that contain only what's necessary for your application to run.

In a traditional single-stage build, all build dependencies, compilation tools, and intermediate artifacts end up in your final image, creating a bloated container that carries unnecessary weight and increases your attack surface. Multi-stage builds introduce multiple stages in your Dockerfile, each serving a specific purpose, effectively running different parts of your build in isolated environments that can be optimized independently.

The magic of multi-stage builds lies in their ability to compile and build artifacts in one stage and then copy only the essential artifacts to a clean, minimal runtime stage. For interpreted languages like JavaScript, Ruby, or Python, you can build and minify your code in one stage before copying the production-ready files to a smaller runtime image. For compiled languages like Go, Rust, or C++, multi-stage builds let you compile in one stage containing the full compiler toolchain and then copy only the resulting binaries to a final runtime image that doesn't include the compiler at all.

This separation means your production images can be orders of magnitude smaller than their single-stage counterparts, directly translating to faster deployment times, reduced storage costs, and smaller attack surfaces. To learn how to apply these patterns to Next.js applications, see our guide on Docker for Next.js.

Dockerfile
1# Stage 1: Build Environment2FROM maven:3.9-eclipse-temurin-21 AS build3WORKDIR /app4COPY pom.xml ./5COPY src ./src6RUN mvn clean package -DskipTests7 8# Stage 2: Runtime Environment9FROM eclipse-temurin:21-jre-jammy10WORKDIR /app11COPY --from=build /app/target/*.jar app.jar12EXPOSE 808013ENTRYPOINT ["java", "-jar", "app.jar"]

This example demonstrates a typical multi-stage build for a Java application. The first stage uses a full Maven image with the JDK to compile and package the application, while the second stage uses a much smaller JRE-only image to run the final artifact. The result is an image that contains only the Java runtime and your application JAR file, without any of the Maven build tools, source code, or test dependencies that were required during the build process.

For compiled languages like Go or Rust, multi-stage builds can reduce image sizes from over 1GB to under 50MB, demonstrating the dramatic impact this technique can have on your containerized deployments. If you're deploying AI-powered applications, multi-stage builds are particularly valuable given the typically large base images for ML frameworks.

Choosing the Right Base Image

The foundation of every Docker image is the base image you select, and this choice significantly impacts your image's size, security, and compatibility. Docker Hub hosts thousands of official and community-contributed images, but not all base images are created equal.

Official images from Docker and verified publishers undergo security scanning and maintenance, making them a safer starting point than images from unknown sources. The key consideration when selecting a base image is matching the image's capabilities to your actual runtime requirements.

Many applications can run perfectly well on minimal base images like Alpine Linux, which typically weighs in at around 5MB compared to hundreds of megabytes for full distribution images. Alpine uses musl libc instead of glibc, which is important to note for applications that have specific library dependencies, but for the vast majority of applications, Alpine provides a perfectly compatible and significantly smaller foundation.

For language-specific runtimes, official images typically offer multiple variants including full images with complete development tools, slim images with minimal runtime only, and Alpine variants combining minimal size with specific language runtimes. When containerizing modern web applications, choosing the right base image is often the first step toward an optimized deployment.

Pinning Image Versions

A critical practice that is often overlooked is the pinning of image versions in your Dockerfiles. Using floating tags like latest or version-less tags like ubuntu creates an inherent instability in your builds, as the same Dockerfile can produce different images depending on when it's executed.

This unpredictability can lead to production incidents caused by unexpected changes in base images, security vulnerabilities introduced through automatic updates, and reproducibility issues that make debugging more difficult. Version pinning ensures that every build produces the same result, enabling reliable caching, predictable deployments, and easier troubleshooting when issues arise.

When you pin versions, you maintain control over when base images are updated, allowing you to test and validate new versions before incorporating them into your production pipelines. This controlled update cycle is essential for maintaining security, as it gives you the opportunity to scan new base image versions for vulnerabilities before deployment.

Dockerfile
1# Avoid this - unpredictable2FROM node:latest3FROM ubuntu:latest4 5# Recommended - pinned versions6FROM node:20.10.0-alpine3.197FROM ubuntu:22.04

Optimizing Layer Caching

Docker's layer caching system is a powerful mechanism that can dramatically accelerate your build times when leveraged effectively, but it can just as easily become a bottleneck when layer ordering is suboptimal. Each instruction in your Dockerfile creates a new layer, and Docker caches layers based on the instruction text and its position in the file.

When a layer changes, all subsequent layers must be rebuilt, making the order of instructions critical for build performance. The fundamental principle for effective layer caching is to order your Dockerfile instructions from least frequently changing to most frequently changing.

Files that rarely change, such as dependency declarations and configuration files, should be copied and processed early in the build, while files that change on every code edit, such as source code files, should be copied later. This ordering ensures that changes to source code don't invalidate the cache for dependency installation steps, which typically take the longest time to execute.

Beyond basic ordering, the .dockerignore file serves a crucial role by preventing unnecessary files from being sent to the Docker daemon, which not only reduces build context size but also prevents file changes from invalidating the cache when those files aren't actually needed in the image. For comprehensive coverage of Docker development workflows, including caching strategies and team collaboration patterns, see our Docker Development Workflow guide.

Dockerfile
1# Optimal layer ordering for Node.js2FROM node:20-alpine3 4WORKDIR /app5 6# Copy package files first - changes infrequently7COPY package* yarn.lock ./8 9# Install dependencies - cached unless packages change10RUN npm ci --only=production11 12# Copy source code last - changes frequently13COPY . .14 15# Build application if needed16RUN npm run build17 18EXPOSE 300019CMD ["node", "dist/index.js"]

Security Best Practices

Security in containerized environments requires a defense-in-depth approach that addresses vulnerabilities at every layer of your Docker infrastructure. From the base image you select to the runtime configuration of your containers, each decision point represents an opportunity to either strengthen or weaken your security posture.

The container attack surface is particularly concerning because Docker containers share the host kernel, meaning that a kernel vulnerability can potentially be exploited from within any container, while container escape vulnerabilities can allow attackers to access the host system from within a compromised container.

One of the most impactful security practices is running containers as non-root users. By default, Docker containers run as the root user, which means any process that escapes its container boundaries or gains unauthorized access to the container will have root privileges on the host system. Creating a dedicated user in your Dockerfile and using the USER instruction to switch to that user dramatically limits the potential damage from a container compromise.

For comprehensive container security, also consider implementing read-only filesystems, dropping unnecessary Linux capabilities, and using secrets management for sensitive data. These practices work together to create a robust security posture for your containerized applications. Learn more about securing your container infrastructure in our Docker Security guide.

Dockerfile
1# Create non-root user2RUN addgroup -g 1000 appgroup && \3 adduser -u 1000 -G appgroup -s /bin/sh -D appuser4 5# Copy application files6COPY --chown=appuser:appgroup dist /home/appuser/app7 8# Switch to non-root user9USER appuser

Scanning and Vulnerability Management

Proactive vulnerability management is essential for maintaining secure container images in production. Automated scanning tools should be integrated into your CI/CD pipelines to analyze images for known vulnerabilities before they reach production environments.

These tools compare image contents against databases of known vulnerabilities, identifying outdated packages, insecure configurations, and potentially malicious components. Docker Scout and other commercial and open-source scanners provide varying levels of analysis, from basic package vulnerability detection to comprehensive software composition analysis that identifies license compliance issues and supply chain risks.

Beyond scanning, maintaining a regular update cadence for your base images ensures you receive security patches as they become available. Automated tools can monitor your images for new vulnerabilities and trigger rebuilds when critical patches are released, ensuring your production images remain protected against newly discovered threats.

Dockerfile Instruction Best Practices

The Dockerfile is the blueprint for your container images, and the instructions you use determine not only what ends up in your image but also how efficiently it can be built and maintained. Each Dockerfile instruction has specific semantics that affect layer caching, image size, and build reproducibility.

The RUN instruction executes commands in a new layer and is where most image customization occurs. Each RUN instruction creates a new layer, which means that combining related operations into single instructions reduces layer count while maintaining distinct operations as separate instructions improves cache efficiency. The choice between these approaches depends on how frequently each operation's inputs change.

Additionally, always clean up within the same RUN instruction that creates temporary files, as each layer is immutable and cleanup in a subsequent layer doesn't reduce the previous layer's size. This understanding of layer mechanics is fundamental to writing efficient Dockerfiles. For teams building AI-powered applications, optimized Dockerfiles can significantly reduce deployment times and resource consumption.

COPY vs ADD Instruction Semantics

The COPY and ADD instructions both serve the purpose of adding files to your image, but they have distinct use cases that should guide your choices. COPY is the straightforward choice for most cases, copying files and directories from the build context into the image with transparent behavior that's easy to predict and debug.

ADD has additional capabilities including remote URL support and automatic tar extraction, but these features come with security considerations and should be used judiciously. The automatic extraction of tar files can lead to surprising behavior when archives contain nested structures, while downloading from URLs can introduce external dependencies and potential security risks if the remote source is compromised.

Reserve ADD for cases where its unique capabilities are genuinely required, and document why ADD was chosen when used, as future maintainers may question the decision otherwise. The explicit nature of COPY makes it the preferred choice for most file copying operations, improving Dockerfile readability and reducing the potential for unexpected behavior.

Dockerfile
1# COPY - preferred for local files2COPY package.json package-lock.json ./3COPY src/ ./src/4 5# ADD - only when you need its specific features6ADD https://example.com/config.yaml /app/config.yaml7ADD bundle.tar.gz /app/8 9# Clean up in the same layer10RUN apt-get update && \11 apt-get install -y --no-install-recommends \12 curl \13 && rm -rf /var/lib/apt/lists/*

Frequently Asked Questions

Ready to Optimize Your Docker Workflow?

Implementing these Docker best practices will significantly improve your container images' security, performance, and maintainability. Start with multi-stage builds and non-root users for the biggest immediate impact, then gradually adopt the remaining practices as part of your containerization maturity. Explore our comprehensive Docker guides to deepen your container expertise.

Sources

  1. Docker Building Best Practices - Official Docker documentation covering multi-stage builds, base image selection, layer caching, and ephemeral containers
  2. Better Stack: Best Practices for Building Docker Images - Comprehensive guide on image optimization, security practices, and maintainability
  3. Cloud Native Now: Docker Security in 2025 - Security-focused guide covering vulnerabilities, runtime security, and configuration hardening
  4. Docker Multi-stage Builds - Detailed explanation of multi-stage build concept and implementation