Docker images serve as the foundation for your containerized applications. How you build these images directly impacts your application's security, performance, reliability, and maintenance overhead. A well-crafted Docker image leads to consistent behavior across environments, faster deployments, and a more secure application stack.
This article explores essential best practices for building Docker images that are efficient, secure, and maintainable. I'll provide clear examples of what to do and what to avoid when crafting your Dockerfiles, along with practical code snippets you can adapt to your own projects.
Multi-stage builds for optimal image size
One of the most powerful techniques for creating efficient Docker images is using multi-stage builds. This approach allows you to use one stage for building your application and another stage for running it, resulting in significantly smaller images.
❌ Don't do this
A common mistake is building everything in a single stage, which includes all build dependencies in your final image:
This approach creates a bloated image containing npm, build tools, and other dependencies that aren't needed at runtime.
✅ Do this instead
Use multi-stage builds to separate the build environment from the runtime environment:
This example uses a full Node.js image for building the application but then copies only the necessary files to a slimmer runtime image. The final image excludes all the build tools and dependencies, resulting in a much smaller size.
Multi-stage builds also enable parallel building of stages when possible, which can speed up your build process considerably.
Choose the right base image
The foundation of your Docker image is the base image you select. This choice significantly impacts security, size, and functionality.
❌ Don't do this
Don't automatically reach for popular but bloated base images:
This approach brings in an entire Ubuntu distribution with many unnecessary packages.
✅ Do this instead
Select the smallest base image that meets your requirements:
Even better, consider using Alpine-based images for extremely small footprints:
Look for base images with official badges from Docker Hub or verified publisher marks, as these tend to be more secure and better maintained. For production environments, consider having separate image selections for development/testing and production use cases.
Pin image versions with specific tags
Using specific version tags and even image digests ensures consistency across builds and environments.
❌ Don't do this
Don't use floating tags like latest that can change unexpectedly:
This approach means you might get different Node.js versions over time, potentially breaking your application.
✅ Do this instead
Pin to a specific version tag:
For maximum reliability, use a digest hash to guarantee the exact image:
Using specific tags or digests makes your builds deterministic and reproducible. While you may miss automatic updates for security patches, you gain complete control over when to update base images, reducing unexpected breaks.
Optimize layer caching
Docker builds images in layers, and each instruction in a Dockerfile creates a new layer. Understanding this layering system allows you to optimize caching for faster builds.
❌ Don't do this
Don't invalidate the cache unnecessarily by placing frequently changing files early in your Dockerfile:
Every time you change a source file, this invalidates the cache for all subsequent steps, including the npm install command.
✅ Do this instead
Order your instructions from least to most frequently changing:
This way, Docker can use the cached layer for the dependency installation step as long as your package files haven't changed, even if your source code has.
You can also use the --no-cache flag for critical steps where you always want to get the latest versions:
Properly handle apt-get and other package managers
When using package managers like apt-get in Debian-based images, proper usage patterns can prevent common issues.
❌ Don't do this
Don't separate update and install commands:
This approach caches the apt-get update result, which means later builds might install outdated packages.
✅ Do this instead
Always combine update and install in a single RUN instruction:
This pattern ensures:
- The package list is always updated before installation
- Unnecessary recommended packages are excluded
- The apt cache is cleaned up, reducing image size
- All packages are installed in a single layer
For version pinning with apt, you can specify exact versions:
Use .dockerignore to exclude unnecessary files
The .dockerignore file helps you exclude files and directories from the build context, improving build performance and preventing sensitive information from being included in your images.
❌ Don't do this
Don't send your entire project directory as build context:
This includes logs, temp files, Git repositories, and other files not needed for the build.
✅ Do this instead
Create a .dockerignore file to exclude unnecessary files:
This reduces the build context size, speeds up the build process, and helps prevent sensitive information from leaking into your images.
Create ephemeral containers
Containers should be ephemeral—easily stopped, destroyed, and replaced with minimal configuration.
❌ Don't do this
Don't design containers that require special setup or store state internally:
✅ Do this instead
Design for statelessness and use volumes for persistent data:
When you run this container, mount the volume to persist data:
This approach allows containers to be replaced without data loss and follows the principles of the twelve-factor application methodology.
Use appropriate instructions for ENV, COPY, and ADD
Understanding the nuances between similar Dockerfile instructions can help you choose the most appropriate one for your use case.
ENV for environment variables
❌ Don't do this
Don't create multiple layers for environment variables:
✅ Do this instead
Environment variables should be grouped logically:
For variables that shouldn't persist in the final image, use this pattern:
COPY vs ADD
❌ Don't do this
Don't use ADD for simple file copying:
✅ Do this instead
COPY is simpler and more explicit for basic file copying:
ADD has additional features like auto-extraction of archives and URL support:
Generally, prefer COPY unless you specifically need ADD's extra functionality.
Set the appropriate user
Running containers as non-root users improves security by reducing the potential impact of container breaches.
❌ Don't do this
Don't run everything as root by default:
✅ Do this instead
Create and use a non-privileged user:
For applications that don't need to bind to privileged ports (below 1024), running as a non-root user from the start is even better.
Use WORKDIR instead of RUN cd
The WORKDIR instruction sets the working directory for subsequent instructions in a clear, explicit way.
❌ Don't do this
Don't use RUN cd commands to change directories:
This approach is error-prone, harder to read, and doesn't actually change the working directory for subsequent instructions.
✅ Do this instead
Use WORKDIR to set the working directory:
WORKDIR creates the directory if it doesn't exist and makes the intention clearer. It also simplifies file paths in subsequent instructions.
Final thoughts
Building efficient Docker images requires attention to detail and an understanding of Docker's layering system. By following these best practices—using multi-stage builds, choosing appropriate base images, optimizing caching, and implementing proper security measures—you can create images that are smaller, more secure, and easier to maintain.
Remember that the Dockerfile is essentially documentation for how your application should be built and run. A clean, well-structured Dockerfile makes it easier for others (and your future self) to understand how your application works.
The practices outlined here should serve as guidelines rather than rigid rules. As with all aspects of software development, understanding the reasoning behind each practice allows you to make informed decisions about when to follow them and when your specific use case might require a different approach.
Thanks for reading!