Dockerfile Best Practices
A practical guide to writing efficient, secure and maintainable Dockerfiles for production applications.
A Dockerfile is more than a list of commands used to build a container image. The way a Dockerfile is structured affects image size, build speed, cache efficiency, security and how easy the application is to maintain. Two Dockerfiles can produce functionally identical applications while having very different build performance and operational characteristics.
Following Dockerfile best practices helps you create images that are smaller, faster to build, easier to update and less exposed to unnecessary security risks. These practices are useful for everything from small development projects to production applications built in CI/CD pipelines.
Why Dockerfile Best Practices Matter
A Docker image is built from a sequence of filesystem layers. Many Dockerfile instructions can create new layers, and Docker can reuse previously built layers when the corresponding instructions and their inputs have not changed. This means Dockerfile structure directly affects build performance.
A poorly structured Dockerfile can also make images unnecessarily large. Installing development tools, package caches and temporary files that are not required at runtime increases the amount of data that must be stored, transferred and scanned.
Security is another important consideration. Every package and executable included in an image increases its attack surface. Running an application as root, copying secrets into an image or using an unnecessarily large base image can introduce avoidable risks.
- Reduce Docker image size.
- Improve build and deployment speed.
- Make Docker layer caching more effective.
- Reduce the number of unnecessary dependencies.
- Improve container security.
- Make Dockerfiles easier to understand and maintain.
- Produce more predictable builds.
1. Choose an Appropriate Base Image
The FROM instruction defines the base image from which your image is built. Choosing the right base image is one of the first decisions that affects image size, compatibility and security.
FROM node:22A general-purpose image can contain many packages and utilities that your application does not need. A smaller runtime-oriented image can reduce the final image size, but extremely minimal images can sometimes make debugging or compatibility more difficult.
Do not choose a base image solely because it has the smallest possible size. The correct choice depends on the application, required system libraries, runtime compatibility and operational requirements.
2. Use Explicit Image Versions
Using a floating tag such as latest makes builds less predictable because the tag can point to a different image over time.
FROM node:22
# Less predictable
FROM node:latestAn explicit version makes it easier to reproduce builds and understand which runtime version the application uses. For environments where strong reproducibility is required, image digests can provide even more precise pinning.
3. Keep the Dockerfile Simple
A Dockerfile should describe the minimum steps required to build and run the application. Avoid adding commands that are unrelated to the final image or that are only useful for temporary debugging.
FROM node:22
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
CMD ["npm", "start"]Simple Dockerfiles are easier to review, debug and modify. They also make it easier to understand which commands affect dependencies, source code and the runtime environment.
4. Use .dockerignore
A .dockerignore file prevents unnecessary files from being sent to the Docker build context. This can significantly reduce the amount of data Docker needs to process during a build.
node_modules
.git
.gitignore
.env
.env.*
npm-debug.log
Dockerfile
docker-compose.yml
.next
dist
coverageIgnoring node_modules is especially important for JavaScript and TypeScript applications. Dependencies should normally be installed inside the image rather than copied from the host machine.
Environment files containing secrets are another important category. If a secret does not need to be part of the image, it should not be included in the build context or copied into the image.
5. Order Instructions for Better Caching
Docker can reuse cached layers when earlier build steps have not changed. This makes instruction ordering one of the most useful Dockerfile optimization techniques.
For applications with dependency manifests, copy those files before copying the rest of the source code. Dependency files change less frequently than application source code, so Docker can often reuse the dependency installation layer.
FROM node:22
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
CMD ["npm", "start"]If you copied the entire project before running npm ci, a small source-code change could invalidate the dependency installation layer as well. Separating dependency files from source files gives Docker more opportunities to reuse the cache.
6. Prefer npm ci for Reproducible Node.js Builds
For Node.js applications that use npm, npm ci is generally intended for clean, automated installations based on the lockfile. It is particularly useful in Docker builds and CI environments.
COPY package.json package-lock.json ./
RUN npm ciThe same principle applies to other ecosystems: use the package manager's reproducible or lockfile-based installation mechanism when one is available.
7. Combine Related RUN Commands Carefully
A Dockerfile can contain multiple RUN instructions, but each unnecessary layer can increase the final image size or make cleanup less effective. When installing packages and removing package-manager caches, keeping related operations together can prevent temporary files from remaining in an earlier layer.
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*The important part is not blindly minimizing the number of RUN instructions. Commands should be grouped when they form one logical operation, especially when cleanup needs to happen in the same layer.
8. Clean Package Manager Caches
Package managers can leave cached package indexes, archives or temporary files behind. These files are often unnecessary in the final runtime image.
RUN apt-get update \
&& apt-get install -y --no-install-recommends ca-certificates \
&& rm -rf /var/lib/apt/lists/*The exact cleanup procedure depends on the Linux distribution and package manager. The goal is to avoid shipping files that provide no value at runtime.
9. Use Multi-Stage Builds
Multi-stage builds allow you to use one stage for compiling or building the application and another stage for running it. This is particularly useful when the build process requires compilers, development dependencies or other tools that are unnecessary at runtime.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
CMD ["node", "dist/server.js"]The final stage contains only the files required to run the application. Build-only dependencies remain in the builder stage instead of being included in the runtime image.
10. Do Not Install Development Tools in Runtime Images
Compilers, test frameworks, linters and other development tools usually do not need to exist in a production runtime image.
Installing everything into one image is convenient during development, but it increases image size and the number of components that need to be maintained and scanned.
Multi-stage builds provide a clean way to keep these dependencies in the build environment while copying only the runtime artifacts into the final image.
11. Run Containers as a Non-Root User
Containers should generally run applications with the least privileges required. Running an application as root can increase the potential impact of a container compromise.
FROM node:22-slim
WORKDIR /app
COPY --chown=node:node package*.json ./
RUN npm ci --omit=dev
COPY --chown=node:node . .
USER node
CMD ["node", "server.js"]Many official runtime images already provide a non-root user. When they do, using that user can be simpler than creating one manually. If your application requires a custom user, create one explicitly and give it only the permissions it needs.
12. Do Not Store Secrets in Dockerfiles
API keys, passwords, private tokens and other credentials should not be hardcoded into Dockerfiles.
# Do not do this
ENV API_KEY="my-secret-key"
# Also avoid
RUN echo "my-secret-key" > /app/secret.txtDocker images can be inspected, and values added during image construction can potentially remain accessible through image metadata or layers. A secret that is no longer visible in the final filesystem is not automatically safe if it was included during the build.
Use the secret-management mechanisms provided by your deployment environment and Docker's supported build-time secret features when a secret is genuinely required during a build.
13. Use ENV for Configuration, Not Secrets
Environment variables are useful for runtime configuration such as ports, feature flags and non-sensitive application settings.
ENV NODE_ENV=production
ENV PORT=3000Sensitive credentials should instead be injected through the runtime environment or a dedicated secret-management system. This keeps configuration separate from the image itself.
14. Use COPY Instead of ADD for Ordinary File Copies
COPY is usually the clearer instruction when the goal is simply to copy files or directories from the build context into the image.
COPY package.json package-lock.json ./
COPY src ./srcADD has additional behavior, including handling certain archive formats and remote sources. Because COPY communicates a simpler intent, it is generally preferred when those extra features are not required.
15. Use JSON Form for CMD and ENTRYPOINT When Appropriate
Docker supports shell and exec forms for commands. The JSON or exec form is often preferable for application processes because it allows the application to receive signals more directly and avoids unnecessary shell interpretation.
CMD ["node", "server.js"]This is especially relevant for processes that need to handle termination signals correctly when containers are stopped or restarted.
16. Define a Clear Container Process
A container should normally have a clear primary process. The CMD or ENTRYPOINT instruction should describe what the container is intended to run.
ENTRYPOINT ["node"]
CMD ["server.js"]ENTRYPOINT is useful when the image represents a specific executable or application, while CMD commonly provides default arguments or a default command that can be overridden.
17. Avoid Unnecessary Processes in a Container
A common container design is to run one primary application process per container. Instead of putting a web server, database, background worker and unrelated services into one container, separate services can usually be represented by separate containers and coordinated with an orchestration or composition system.
This separation makes scaling, monitoring and deployment more predictable. It also keeps each image focused on a specific responsibility.
18. Keep the Build Context Small
The Docker build context is the set of files made available to the build. Sending large directories that are not required by the Dockerfile can slow down builds.
A well-designed .dockerignore file is therefore part of Dockerfile optimization, not an unrelated configuration detail.
- Exclude Git history.
- Exclude dependency directories that are installed in the image.
- Exclude build output that is generated inside the image.
- Exclude local caches and logs.
- Exclude environment files containing sensitive data.
- Exclude editor and operating-system files.
19. Make Builds Reproducible
A reproducible Docker build should produce a predictable result from a known set of inputs. Lockfiles, explicit base-image versions and controlled dependency installation all contribute to this goal.
Reproducibility is particularly important in CI/CD because an application should not unexpectedly change merely because a dependency or base-image tag changed between two builds.
20. Use BuildKit Features When Useful
Modern Docker builds can use BuildKit capabilities that improve caching, parallelism and secret handling. BuildKit can also provide more advanced cache strategies for CI environments.
For example, cache mounts can allow package managers to reuse downloaded dependencies between builds without placing those caches into the final image.
RUN --mount=type=cache,target=/root/.npm \
npm ciThe exact cache location depends on the package manager and build environment. The important distinction is that a build cache is different from files that should be shipped in the final image.
21. Avoid Installing Packages You Do Not Need
Every additional operating-system package increases image size and potentially adds security maintenance requirements. Before installing a utility, consider whether it is actually needed at runtime.
For example, tools used only to inspect files or debug a running container may be useful during development but unnecessary in production.
22. Use Explicit Working Directories
WORKDIR defines the working directory for subsequent instructions and for the container process. It is clearer and safer than repeatedly using cd inside shell commands.
WORKDIR /app
COPY . .
RUN npm run buildWORKDIR also makes the Dockerfile easier to read because the location in which commands operate is explicit.
23. Use Environment Variables for Runtime Configuration
Applications often need different configuration values in development, staging and production. Hardcoding those values into the image makes the image less reusable.
ENV PORT=3000
EXPOSE 3000The image can then be configured differently when the container is started. Keep in mind that EXPOSE documents the intended port; it does not itself publish the port to the host.
24. Do Not Confuse EXPOSE with Port Publishing
The EXPOSE instruction documents which port the application expects to use inside the container.
EXPOSE 3000It does not automatically make the application accessible from the host machine. Port publishing is configured when the container is started or through a composition or orchestration configuration.
25. Use Health Checks When They Add Value
A health check can provide information about whether an application inside a container is actually responding as expected rather than merely having a running process.
HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \
CMD ["curl", "-f", "http://localhost:3000/health"] || exit 1Health checks should test something meaningful. A check that only verifies that a process exists may not detect application-level failures.
26. Keep Application Data Out of the Image When Possible
Containers are generally easier to replace when important mutable data is stored outside the image. Databases, uploaded files and other persistent application data often belong in dedicated storage rather than being baked into a Docker image.
This makes it possible to rebuild or replace the application image without accidentally replacing persistent data.
27. Separate Development and Production Images When Needed
Development environments often need hot reloading, source maps, debuggers and large sets of development dependencies. Production images usually need a much smaller runtime environment.
Trying to force both use cases into exactly the same image can make the production image unnecessarily large. Multi-stage builds, separate targets or dedicated development configurations can provide a cleaner separation.
28. Optimize Dockerfiles for CI/CD
Dockerfile optimizations become particularly valuable when images are built frequently in continuous integration systems. Dependency layers should be cacheable, the build context should remain small and unnecessary work should be avoided.
A useful CI-oriented Dockerfile often follows this general order: select the runtime, configure the working directory, copy dependency manifests, install dependencies, copy source files, build the application and create a minimal runtime stage.
29. Scan Images for Security Issues
Dockerfile best practices reduce unnecessary components, but they do not replace vulnerability scanning. Base images and application dependencies can contain vulnerabilities even when the Dockerfile itself looks clean.
Use image and dependency scanning as part of your development or CI/CD process. Pay particular attention to high-impact vulnerabilities in packages that are actually present in the runtime image.
30. Rebuild Images Regularly
A Dockerfile is not the only thing that determines the contents of an image. Base images and dependencies evolve over time, so an image that was safe when initially built can become outdated.
Regular rebuilds provide an opportunity to receive updated base images and dependency versions according to your project's versioning and testing strategy.
Example of a Production-Oriented Dockerfile
The following example combines several of the practices discussed above: explicit dependency installation, cache-friendly ordering, a multi-stage build, a smaller runtime image and a non-root user.
FROM node:22 AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
RUN npm run build
FROM node:22-slim AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV PORT=3000
COPY package.json package-lock.json ./
RUN npm ci --omit=dev
COPY --from=builder /app/dist ./dist
USER node
EXPOSE 3000
CMD ["node", "dist/server.js"]The exact Dockerfile depends on the framework and application architecture. A Next.js application, for example, may require a different set of build and runtime files than a simple Node.js server. The principle remains the same: build what is necessary, then keep the runtime image focused on what is actually required to run the application.
Common Dockerfile Mistakes
Many Dockerfiles work correctly but still contain avoidable problems. The following mistakes are particularly common.
| Mistake | Why It Is a Problem | Better Approach |
|---|---|---|
| Using latest everywhere | Builds can change unexpectedly | Use controlled versions or digests where appropriate |
| Copying the entire project before installing dependencies | Reduces cache reuse | Copy dependency manifests first |
| Copying node_modules from the host | Can cause platform and dependency problems | Install dependencies inside the image |
| Including .env files | Can expose sensitive configuration | Use runtime configuration or secret management |
| Running as root | Provides more privileges than necessary | Use a dedicated non-root user |
| Installing build tools in the final image | Increases size and attack surface | Use multi-stage builds |
| Ignoring build context size | Makes builds slower | Use .dockerignore |
| Leaving package caches | Adds unnecessary data | Clean caches when appropriate |
Dockerfile Best Practices Checklist
Before using a Dockerfile in production, review it against a practical checklist.
- Is the base image appropriate for the application?
- Is the base image version controlled?
- Is .dockerignore configured?
- Are dependency files copied before application source?
- Are dependency installations reproducible?
- Can unnecessary build dependencies be removed from the runtime image?
- Would a multi-stage build reduce the final image size?
- Does the container run as a non-root user?
- Are secrets kept out of the Dockerfile and image?
- Are unnecessary operating-system packages excluded?
- Are package-manager caches cleaned when appropriate?
- Is the build context reasonably small?
- Is the container's main process clearly defined?
- Are runtime settings separated from the image?
- Does the application need a health check?
- Are images regularly rebuilt and scanned?
Dockerfile Layers and Caching: The Practical Rule
When optimizing a Dockerfile, think about which inputs change frequently and which inputs change rarely. Dependency manifests usually change less frequently than application source code, while source files can change on almost every commit.
Put stable operations earlier when they can be cached independently, and put frequently changing operations later. This simple principle often provides a significant improvement in development and CI build times.
# Stable inputs first
COPY package.json package-lock.json ./
RUN npm ci
# Frequently changing inputs later
COPY . .
RUN npm run buildDockerfile vs Docker Compose Configuration
A Dockerfile describes how an image is built. Docker Compose configuration, by contrast, is commonly used to describe how one or more containers should be run together.
Keeping these responsibilities separate makes projects easier to understand. The Dockerfile should focus on building the application image, while Compose can define services, networks, volumes, environment configuration and development-specific behavior.
How to Improve an Existing Dockerfile
You do not need to rewrite a Dockerfile from scratch to improve it. Start by inspecting the image size, build time and individual layers. Then identify unnecessary files, packages and build dependencies.
- Add or improve .dockerignore.
- Check whether dependency installation is cache-friendly.
- Remove unnecessary packages.
- Separate build and runtime environments.
- Move secrets out of the image.
- Run the application as a non-root user.
- Review the base image and its version.
- Scan the resulting image.
- Measure build time before and after changes.
Optimization should be measurable. A smaller image is useful, but build speed, startup behavior, security and maintainability are also important. The best Dockerfile is not necessarily the one with the fewest lines; it is the one that produces an appropriate image efficiently and predictably.
Frequently Asked Questions
What is the most important Dockerfile best practice?
There is no single rule that applies to every project. In most production applications, good starting points are using an appropriate base image, configuring .dockerignore, structuring instructions for effective caching, avoiding secrets in images, using multi-stage builds when useful and running the application with appropriate privileges.
Should I always use Alpine images?
No. Alpine can produce small images, but image size is not the only consideration. Native dependencies, compatibility, debugging requirements and application behavior should also influence the base-image choice. A slim Debian-based image may be more appropriate for some applications.
Why should dependency files be copied before source code?
Docker can reuse cached layers when their inputs have not changed. Copying dependency manifests separately means a source-code change does not necessarily invalidate the dependency installation layer, which can make subsequent builds significantly faster.
Should Docker containers run as root?
Applications should generally run with the minimum privileges they require. Using a non-root user can reduce the potential impact of certain container security issues and is a common production practice.
Why use multi-stage Docker builds?
Multi-stage builds separate the environment used to compile or build an application from the environment used to run it. This allows build tools and development dependencies to stay out of the final runtime image.
Can I put API keys in a Dockerfile?
Sensitive API keys and credentials should not be hardcoded into Dockerfiles. Values used during builds or stored in image layers can potentially be exposed. Use runtime environment configuration or appropriate secret-management mechanisms instead.
Does .dockerignore improve Docker image size?
.dockerignore primarily reduces the build context by preventing unnecessary files from being sent to the builder. It can improve build performance and prevent unwanted files from being copied into the image, which can indirectly help keep images smaller.
Helpful Docker Tools
Docker development often involves more than writing Dockerfiles. Useful categories of web tools include Dockerfile generators for creating initial configurations, Docker Compose formatters for keeping Compose files readable, .dockerignore generators for excluding unnecessary files, environment-variable generators for creating configuration templates and EditorConfig generators for maintaining consistent project formatting.
Conclusion
A good Dockerfile should produce an image that contains everything the application needs and as little as possible beyond that. The most useful practices are straightforward: choose an appropriate and controlled base image, keep the build context small, use .dockerignore, order instructions for caching, install dependencies reproducibly, separate build and runtime environments, avoid secrets in images and run applications with appropriate privileges.
Dockerfile optimization should also be treated as an ongoing process. As the application, dependencies and deployment environment change, review image size, build times, vulnerabilities and runtime requirements. A carefully structured Dockerfile makes those changes easier to manage and provides a stronger foundation for reliable containerized deployments.