6 Best Practices for Writing Dockerfiles

My article is open to everyone; non-member readers can click this link to read the full text.
Since Docker was introduced in 2013, it has developed for over ten years and has now become the industry standard for container technology.
It supports all major operating systems and cloud platforms, and it can containerize almost any type of application, enabling applications to be easily migrated between different machines, clusters, or even cloud services.
Every Docker container is built from a Dockerfile, so it is essential to follow best practices when writing a Dockerfile.
Let’s explore some of these practices below.
1. Add Files as Needed
When writing a Dockerfile, the most critical aspect is to consider the caching mechanism.
Every time you build a Docker image from a Dockerfile, Docker saves the cache generated during the build process.
If the cache is available when you rebuild the image, it can significantly speed up the build process. For example, executing the npm install command might take a few minutes to download and install all the dependencies for a Node.js project.
Therefore, you want to utilize this cache when running the docker build command so that the next build can quickly pull from the cache instead of waiting for a few minutes each time, which is both annoying and inefficient.
If you don’t care about caching, your Dockerfile might look like this:
FROM node:20
COPY . .
RUN npm install
RUN npm buildThis Dockerfile uses Docker’s COPY command to add all the project files (including source code) to the image, then executes npm install to install dependencies, and finally runs npm build to build the application from the source code.
While this is feasible, it is not efficient. Suppose you run docker build and then modify some business logic in the project files, now wanting to rebuild.
The first line FROM node:20 remains unchanged, so Docker will use the cache for this part, but the cache will break at the second line COPY . . because the files have changed.
Docker uses a layered caching mechanism, with each line in a Dockerfile typically representing a layer.
This means that once the cache for one layer is broken, all subsequent layers will not use the cache for the build.
This is because Docker assumes that each subsequent layer depends on all previous layers, which is a reasonable assumption.
In our example, npm install will run when the project files change, but it actually doesn’t depend on the project’s source code; it only depends on the package.json and package-lock.json files.
The package.json defines all the dependencies that npm needs to install. So, let’s improve the Dockerfile:
FROM node:20
COPY package*.json .
RUN npm install
COPY . .
RUN npm buildHere I use package*.json to copy both package.json and package-lock.json.
As you can see, I only copy the entire application’s source code after running npm install and before running npm build, since npm build depends on the source code.
This way, if the source code changes, npm install can be pulled from the cache as long as package.json remains unchanged.
Only if we change some dependencies in package.json do we need to rerun npm install.
Note: The example Dockerfile is intended to illustrate how the caching mechanism works.
A real Node.js application Dockerfile would be different. For example, you should set WORKDIR before adding files and running npm commands.
2. Add a .dockerignore File
When you don’t want to push a file to a Git repository, you add it to the .gitignore file.
Similarly, when you don’t want to include a file in the Docker build context, you should add it to the .dockerignore file.
When building a Docker image, you specify the build context path, such as docker build -t image_tag .
Here, the final dot indicates using the current working directory as the build context.
The build context is then sent (copied) to the Docker daemon, which builds the image.
In a Node.js example, suppose we used npm install and npm start locally to run the application before building the Docker image.
Since these commands are executed directly on the local machine, npm creates a node_modules directory in the project directory to store all downloaded dependencies.
The project directory structure might look like this:
node_modules/
public/
src/
package.json
package-lock.jsonNote that the node_modules directory can easily reach 1GB in size.
Suppose we tested the application locally with npm start and now want to build the Docker image for the application.
So, we created a Dockerfile in the project directory similar to the one mentioned earlier.
Then we execute the command docker build -t image_tag . However, looking at the build log, you’ll find that the build context size is nearly 1GB:
=> [internal] load build context
=> => transferring context: 893.00MBThis is because the entire project directory (including node_modules) was sent as the build context.
We want to avoid sending node_modules, so we create a .dockerignore file and add node_modules to it.
Now, when we build the Docker image again, the build log shows that the context size is much smaller than before:
=> [internal] load build context
=> => transferring context: 11.41kBOur project structure now becomes:
node_modules/
public/
src/
Dockerfile
.dockerignore
package.json
package-lock.jsonRemember, the .dockerignore file should always be placed in the root directory of the build context.
You might ask why we exclude node_modules from the build context.
This is because node_modules is a directory created by npm; it does not contain the source code of our application.
It is created by the local npm on our local machine. The npm running in Docker should create its own node_modules inside the Docker image.
Adding the local node_modules to our Docker image is not a good practice.
You should only provide the application’s source code to Docker and then run build commands inside Docker to build the application.
This way, the Docker build will not conflict with your local build.
3. Execute All Commands in One Go
This is straightforward. You often find yourself using apt or other package managers to install the necessary packages.
Before running apt install, you must first run apt update.
Instead of using multiple RUN instructions in a Dockerfile, it’s better to combine them into one:
RUN apt-get update && apt-get install -y \
git \
jq \
kubectlNotice how I break the package names into multiple lines and arrange them alphabetically for better readability.
If you use multiple RUN instructions, each instruction creates a new layer, which can slow down the build process and take up more storage space.
4. Setting Environment Variables and Versions
Using the ENV instruction, you can set environment variables during the build process, which will be retained in the image and available when the container runs.
For example, you can elegantly modify the PATH variable like this:
ENV PATH=/opt/maven/bin:${PATH}Or, if you’re running a Node.js application and reading process.env.PORT
when starting the server, you can set the server's port in the Dockerfile:
ENV PORT=8080It is generally recommended to configure your application using environment variables as much as possible.
When deploying the application, changing an environment variable is always easier than modifying a file in the code and then redeploying the application.
You can also use the ENV instruction to intuitively set the version of certain dependencies:
ENV KUBECTL_VERSION=1.27
RUN curl -fsSL https://pkgs.k8s.io/core:/stable:/v${KUBECTL_VERSION}/deb/Release.key | sudo gpg --dearmor -o /etc/apt/keyrings/kubernetes-apt-keyring.gpg
...When it comes to versions and Docker, it is strongly recommended not to use the “latest” tag for images.
Similarly, you should avoid using overly specific version numbers like 1.27.4, as this will prevent you from receiving important patch updates that may fix bugs or improve security.
Instead, you should use the major (x) or minor (x.y) version numbers:
FROM python:3.10You could also just write “python,” but then Docker will always pull the latest version, which could break your application if there are any breaking changes in the new version.
5. Using Multi-Stage Builds
Multi-stage builds are a powerful and possibly underrated feature of Docker. The concept is to divide the build process of the image into multiple stages.
Ultimately, only the contents of the last stage will be included in the final image, while the previous stages will be discarded.
A typical use case is to use build tools and source code in the first stage to create binary files, then only copy these binary files to the next stage.
The final image will not include the source code or build tools, which makes sense because the final image should only need to run the application, not build it.
FROM mcr.microsoft.com/dotnet/sdk:7.0 AS build-env
WORKDIR /App
# Build the app
COPY . ./
RUN dotnet restore
RUN dotnet publish -c Release -o out
# Build runtime image
FROM mcr.microsoft.com/dotnet/aspnet:7.0
WORKDIR /App
COPY --from=build-env /App/out .
ENTRYPOINT ["dotnet", "DotNet.Docker.dll"]This official sample Dockerfile is used to build and run an ASP.NET application. Notice the COPY --from=build-env /App/out . command, which copies the binary files from the first stage to the second stage.
The first stage is based on an image containing build tools (mcr.microsoft.com/dotnet/sdk:7.0), while the second stage is based on a smaller runtime image (mcr.microsoft.com/dotnet/aspnet:7.0).
Also, note how they specify the minor version of the image.
6. Consider Using Slim and Alpine Images
Alpine images are based on Alpine Linux, which is known for being lightweight, making Alpine images theoretically faster to build, pull, and run.
REPOSITORY TAG SIZE
python 3.10-alpine 50.4MB
python 3.10-slim 128MB
python 3.10 1GBTaking Python as an example, the size of the Alpine image is 1/20th of the full Debian-based image.
Python also offers Slim images, which are based on Debian but have most standard packages removed.
A common issue with these smaller images is that if your application is slightly complex, you may need to install additional packages, which will inevitably extend build time and increase image size, defeating the original purpose of choosing them.





