Meta description: Learn 15 practical Dockerfile best practices to build smaller, faster, more secure, and easier-to-maintain Docker images, with examples.
Suggested URL slug: dockerfile-best-practices
Tags: Docker, DevOps, Containers, Dockerfile, CI/CD
A well-written Dockerfile does more than package an application. It helps you build images faster, reduce image size, improve security, and make deployments more predictable.
You do not need complicated tricks to achieve this. Start with a few reliable practices: choose a suitable base image, order instructions carefully, use multi-stage builds when appropriate, run as a non-root user, and avoid copying unnecessary files into the image.
This guide covers 15 practical Dockerfile tips, with examples you can adapt to your own projects.
1. Choose a suitable base image
Your base image affects image size, compatibility, and security. Prefer an official image that supports your application and has a reasonable maintenance history.
For example, a Python application might start with:
FROM python:3.12-slim
A slim image is often smaller than a full distribution image while retaining common runtime components. However, smaller is not automatically better: some packages need system libraries or build tools. Test your application before switching base images.
Tip: Pin a specific version rather than using a floating tag such as latest. For stricter reproducibility, consider pinning an image digest and updating it through a controlled process.
2. Use a .dockerignore file
Docker sends the build context to the builder. If your context contains virtual environments, Git history, caches, local secrets, or generated files, those can make builds slower and may accidentally end up in the image.

Create a .dockerignore file beside your Dockerfile:
.git
.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.env
.env.*
node_modules
dist
build
Adjust the entries to your project. Do not exclude files the build actually needs, and never rely on .dockerignore as your only secret-protection mechanism.
3. Copy dependency files before application code
Docker can reuse cached layers when earlier instructions have not changed. Copy dependency manifests first, install dependencies, and then copy frequently changing source files.
For a Python application:
FROM python:3.12-slim
WORKDIR /app
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY . .
CMD ["python", "app.py"]
If you change only app.py, Docker can often reuse the dependency-installation layer. If you change requirements.txt, that layer must be rebuilt---which is exactly what you want.
4. Combine related RUN commands thoughtfully
Each Dockerfile instruction generally creates a layer. Combining related package-installation and cleanup commands can prevent package-manager caches from remaining in the final image.
For Debian-based images:
RUN apt-get update \
&& apt-get install -y --no-install-recommends curl \
&& rm -rf /var/lib/apt/lists/*
Keep apt-get update and apt-get install in the same instruction to avoid stale cached package indexes. Do not combine every command blindly; readable, maintainable instructions are more important than minimizing the number of lines.
5. Use multi-stage builds
Compilers, development dependencies, and build tools often are not needed to run an application. A multi-stage build lets you build in one stage and copy only the required output into a runtime stage.
Example for a Go application:
FROM golang:1.24 AS builder
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY . .
RUN CGO_ENABLED=0 go build -o /out/app .
FROM scratch
COPY --from=builder /out/app /app
ENTRYPOINT ["/app"]
This example assumes the program can run without external shared libraries or certificates. Some applications need a runtime base image such as a slim Debian image instead of scratch.
For Node.js, Java, and frontend projects, the same principle applies: compile or bundle in the build stage, then copy only the runtime files into the final stage.
6. Run the application as a non-root user
Processes running as root inside a container can increase the impact of an application vulnerability. Where possible, create or use a dedicated unprivileged user.
Example:
FROM python:3.12-slim
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
COPY --chown=appuser:appuser requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuser
CMD ["python", "app.py"]
Make sure the application has permission to read its files and write only to the directories it needs. Some official images already provide a suitable non-root user; check the image documentation before creating another one.
7. Use exec form for CMD and ENTRYPOINT
Prefer the JSON-array form:
CMD ["python", "app.py"]
Instead of shell form:
CMD python app.py
Exec form runs the process directly, which generally improves signal handling and helps the application receive termination signals correctly. This matters when Docker or an orchestrator stops a container.
If you need shell features such as variable expansion or pipelines, invoke a shell explicitly and understand the signal-handling implications.
8. Never bake secrets into an image
Do not put passwords, API keys, private keys, or production .env files into an image. Avoid instructions such as:
# Do not do this
ENV DATABASE_PASSWORD="my-secret-password"
COPY .env /app/.env
Image layers and build metadata may persist even if a later instruction deletes the secret. Supply runtime configuration through your deployment platform's secret mechanism. For build-time secrets, use BuildKit secret mounts rather than ordinary ARG or ENV values.
Example BuildKit pattern:
# syntax=docker/dockerfile:1
FROM alpine
RUN --mount=type=secret,id=token \
token="$(cat /run/secrets/token)" && \
some-command-that-needs-the-token
Pass the secret with your build command and ensure the command does not print or persist it.
9. Pin versions and update them regularly
Floating tags can change over time, which may cause a build to behave differently on different days. Pin application runtimes and important dependencies to explicit versions, and use lockfiles where available.
For example:
FROM node:22-bookworm-slim
Pinning alone does not keep an image secure. Establish a routine to rebuild with updated base images and dependencies, run tests, scan for vulnerabilities, and deploy validated updates.
10. Add a health check when it makes sense
A health check can help Docker report whether a service is responding. For an application that exposes a local health endpoint:
HEALTHCHECK --interval=30s --timeout=3s --start-period=20s --retries=3 \
CMD curl -fsS http://localhost:8080/health || exit 1
This requires curl to be installed and the endpoint to exist. Choose a check that reflects real application health without being expensive. Also remember that orchestrators such as Kubernetes have their own liveness and readiness probes; a Dockerfile health check does not replace those.
11. Keep the final image focused
Only include what the application needs at runtime. Avoid copying test data, documentation, local logs, source maps, build caches, and development tools unless they are required.
A multi-stage build and a well-maintained .dockerignore are two of the most effective ways to keep the final image lean.
To inspect image sizes and layers, useful commands include:
docker image ls
docker history your-image:tag
Smaller images can reduce transfer time and storage, but do not remove required libraries or debugging capabilities without considering operational needs.
12. Prefer COPY over ADD for ordinary files
Use COPY when you simply need to copy files from the build context:
COPY src/ /app/src/
ADD has extra behaviors, including automatic handling of local tar archives and support for remote URLs in some cases. Use it only when you specifically need those behaviors. Explicit instructions are easier to review and understand.
13. Use a clear working directory and explicit paths
Set WORKDIR rather than relying on an implicit current directory:
WORKDIR /app
COPY . .
This makes later instructions easier to read and reduces confusion about where files are placed. Prefer explicit destinations and predictable paths, especially in multi-stage builds.
14. Build and tag images consistently
Use meaningful tags that identify the application version or build. Avoid treating latest as a reliable production release identifier.
Example:
docker build -t my-api:1.4.0 .
In CI/CD, consider tagging images with a release version and commit SHA. Deploy the exact image that passed testing, and use immutable digests where your release process supports them.
15. Test and scan images in CI/CD
A successful image build does not prove the application works or that the image is free of known vulnerabilities. A useful pipeline can:
- Build the image.
- Run unit and integration tests.
- Scan the image and dependencies for known vulnerabilities.
- Check that the container starts and responds as expected.
- Publish the validated image to a registry.
- Deploy the same image artifact to the target environment.
Tools such as Docker Scout, Trivy, and registry security scanners can help identify known vulnerabilities. Scanning is not a guarantee of safety; review findings, prioritize fixes, and keep the base image updated.
Example: A practical Python Dockerfile
Here is a compact starting point for a simple Python service:
FROM python:3.12-slim
ENV PYTHONDONTWRITEBYTECODE=1 \
PYTHONUNBUFFERED=1
WORKDIR /app
RUN useradd --create-home --uid 10001 appuser
COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt
COPY --chown=appuser:appuser . .
USER appuser
EXPOSE 8000
CMD ["python", "app.py"]
This example assumes app.py is the correct entry point and the app listens on port 8000. Replace the command and port for your application. For production web services, use the appropriate application server and configuration rather than assuming the development server is suitable.
A matching .dockerignore could look like this:
.git
.venv
venv
__pycache__
*.py[cod]
.pytest_cache
.env
.env.*
Common Dockerfile mistakes to avoid
- Using
latestand assuming it identifies a stable release. - Copying the entire repository---including secrets and local caches---into the image.
- Installing compilers and build tools in the runtime image when they are not needed.
- Running the application as root without a reason.
- Storing credentials in
ENV,ARG, or image files. - Installing dependencies without using a lockfile or controlled versions.
- Using shell-form commands without considering signal handling.
- Treating an image build or vulnerability scan as a substitute for application testing.
Final checklist
Before publishing a Docker image, ask:
- Is the base image suitable and maintained?
- Is the build context restricted with
.dockerignore? - Are dependency files copied before frequently changing source code?
- Can a multi-stage build remove unnecessary build tools?
- Does the process run as a non-root user?
- Are secrets kept out of the image and build logs?
- Are dependency and base-image updates managed?
- Does the container start, stop, and respond correctly?
- Is the image tested and scanned in CI/CD?
Conclusion
The best Dockerfile is not necessarily the shortest one. It is one that produces a predictable image, contains only what is needed, handles configuration securely, and is easy for your team to maintain.
Start with the fundamentals: use .dockerignore, order layers for cache reuse, avoid baking secrets into images, run as a non-root user, and test every release. Add multi-stage builds and more advanced optimizations when they solve a real problem in your application.
<i>What Dockerfile practice has made the biggest difference in your projects? Share your experience in the comments.</i>

Responses (0)
Join the conversation
Sign in to share your thoughts and talk with the author.
Sign in to respondNo responses yet. Be the first to share your thoughts!