In this post, we'll dive into the best practices for writing Dockerfiles that will help you create efficient, secure, and maintainable Docker images while reducing build times.
Using an Appropriate Parent Image
One of the most important things you can do to build efficient Docker images is to choose the right base image. It's always a good idea to start with official images from Docker Hub as your foundation when building custom images. These official images are well-maintained, follow best practices, have comprehensive documentation, and receive regular security updates.
For example, if your application needs the Java Development Kit (JDK), it's more efficient and secure to use an official Docker image like eclipse-temurin
instead of starting from scratch with a generic ubuntu
image and manually installing the JDK. This way, you can leverage the work that has already been done to ensure the JDK is properly configured and secure within the Docker environment.
Inefficient Dockerfile | Efficient Dockerfile |
---|---|
FROM ubuntu RUN apt-get update && apt-get install -y openjdk-11-jdk |
FROM eclipse-temurin |
Secondly, it's crucial to avoid using the latest
tag when specifying the parent image in your Dockerfile, especially for production environments. The latest
tag is a moving target – it always points to the most recent version of the image on Docker Hub. However, as newer versions are released, the latest
tag gets updated, and those updates might not be compatible with your application. This can result in unexpected errors and failures in your production environment.
To prevent this, always use a specific versioned tag when referring to the parent image in your Dockerfile. This ensures that you're building on a stable foundation and avoids the risk of unintended changes caused by automatic updates to the latest
tag.
Inefficient Dockerfile | Efficient Dockerfile |
---|---|
FROM eclipse-temurin:latest | FROM eclipse-temurin:8u412-b08-jre-windowsservercore-ltsc2022 |
And finally, to really slim down your Docker images, it's a good idea to use the minimal version of your base image whenever possible. Many official Docker images on Docker Hub offer a "slim" or "alpine" variant that's specifically designed to be lightweight.
These minimal versions are often based on Alpine Linux, a stripped-down distribution known for its small size and security focus. By using a minimal base image, you can significantly reduce the overall size of your final image, making it faster to build, push, pull, and deploy.
Inefficient Dockerfile | Efficient Dockerfile |
---|---|
FROM eclipse-temurin:8u412-b08-jre-windowsservercore-ltsc2022 | FROM eclipse-temurin:8u412-b08-jre-alpine |
The eclipse-temurin:8u412-b08-jre-alpine
image will be only 51.27 MB in size, whereas eclipse-temurin:8u412-b08-jre-windowsservercore-ltsc2022
will be 2.06 GB in size.
Using a Non-Root User for Better Security
By default, Docker containers run with the power of the root user. This lets them do all sorts of things, like tweaking system settings, installing software, or using special network ports. But here's the problem: that kind of power can be dangerous. If someone manages to hack into your application running inside the container, they might also gain root access to your whole system—not good!
That's why it's considered a security best practice to run containers as a regular, non-root user whenever possible. This follows the principle of least privilege, which means only giving the application the bare minimum permissions it needs to do its job. It's like locking up your valuables when you're not using them—better safe than sorry!
There are two main ways to achieve this in Docker:
-
The
--user
(or-u
) flag: You can use this flag when running a container withdocker run
to specify a different user ID (UID) or username to use inside the container. -
The
USER
directive: You can add this directive to your Dockerfile to set the default user for any containers created from that image.
By using either of these methods, you can significantly reduce the risk of a security breach in case your container is compromised.
If you want to switch things up and run a Docker container as a different user than the default root user, you can use the --user
(or its shorthand -u
) flag when you start the container. This flag gives you the flexibility to specify either the username or the user ID (UID) of the user you want the container to run as.
docker run --user=1234 ubuntu:focal
In the previous command, we set the user ID to 1234 when starting the Docker container. If we're using a user ID instead of a username, the actual user with that ID doesn't need to exist inside the container.
Alternatively, you can use the USER
directive directly in your Dockerfile to specify the default user for your containers. This can be convenient, but keep in mind that you can always override this default setting later on by using the --user
flag when you run the container.
FROM ubuntu:focal
RUN apt-get update
RUN useradd demo-user
USER demo-user
CMD whoami
In the previous example, the USER
directive was added to the Dockerfile to set the default user as demo-user
. This indicates that any commands that follow the USER
directive in the Dockerfile will be executed as if the demo-user
is running them.
Using .dockerignore
The .dockerignore
file is like a "do not disturb" list for Docker. It's a special text file you create in the same folder as your Dockerfile
. Inside this file, you list all the files and folders you don't want Docker to include when it's building your image.
Why is this important? Well, when you run docker build
, Docker first gathers everything in your project folder (called the "build context") and packages it up into a TAR archive. It then sends this archive to the Docker daemon, which actually builds the image.
The first line you usually see in the docker build
output is "Sending build context to Docker daemon." This means Docker is uploading that whole package of files. If your project has a lot of files that aren't actually needed to build the image (like temporary files, logs, or large binaries), this can make the build process unnecessarily slow and bloat the size of your final image.
Sending build content to Docker daemon 18.6MB
Step 1/5 : FROM ubuntu:focal
Whenever you build a Docker image, the entire build context—that's everything in your project's directory—gets sent over to the Docker daemon. This can take time and bandwidth, so it's smart to streamline things by excluding unnecessary files.
That's where the .dockerignore
file comes in handy. It acts as a filter, telling Docker which files and folders to leave behind when packaging your build context. Not only does this save time and bandwidth, but it also helps you keep sensitive information like passwords and keys out of your Docker images.
Creating a .dockerignore
file is easy – just place it in the root directory of your project (the same level as your Dockerfile
). Docker will automatically look for it and use its instructions to exclude unwanted files when building the image.
The following is the content of a sample .dockerignore
file:
# Ignore the node_modules
directory node_modules
# Ignore log files
*.log
# Ignore test files
**/*.test.js
# Ignore configuration files
.env
# Ignore hidden files
.*
# Ignore build directories
build/
# Ignore the Dockerfile itself (optional, but good practice)
Dockerfile*
# Ignore specific files
temp.txt
This example .dockerignore
file excludes:
- The
node_modules
directory (commonly used for Node.js dependencies). - Any file ending in
.log
(log files). - Any JavaScript file ending in
.test.js
(test files). - The
.env
file (environment variables). - All hidden files (files starting with a dot).
- The
build/
directory. - Any Dockerfile (optional, but recommended to prevent accidental inclusion).
- The file
temp.txt
.
Minimizing Layers
Every line in your Dockerfile translates to a new layer in your Docker image, and each layer adds to the overall size. To keep things lean, it's a good idea to create as few layers as possible. One way to do this is by combining multiple RUN
instructions whenever you can.
Let's take a look at an example Dockerfile that installs redis-server
and nginx
:
FROM ubuntu:latest
RUN apt-get update
RUN apt-get install -y nginx
RUN apt-get install -y redis-server
In this case, we have three separate RUN
instructions: one to update the package list, one to install nginx
and another to install redis-server
. This creates three separate layers in the image.
To optimize this, we can combine the three RUN
instructions into one:
FROM ubuntu:latest
RUN apt-get update && apt-get install -y redis-server nginx
By using the &&
operator, we tell Docker to execute all three commands in a single layer, reducing the overall size of the image. This is a simple yet effective way to make your Docker images more efficient.
Don't Install Unnecessary Tools
Avoiding unnecessary tools and dependencies is a key strategy for creating efficient Docker images. For example, if you're building an image for a production environment, you likely don't need debugging tools like vim
, curl
, or telnet
. By leaving these out, you can significantly reduce the size of your image.
Similarly, be mindful of the dependencies that your package manager installs. Some package managers, such as apt
, will automatically install additional "recommended" or "suggested" packages along with the ones you explicitly request. This can bloat your image unnecessarily. To prevent this, you can use the --no-install-recommends
flag with the apt-get install
command. This tells apt
to install only the essential packages you need and skip any optional extras.
FROM ubuntu:latest
RUN apt-get update && apt-get install --no-install-recommends -y nginx
In the previous example, using the --no-install-recommends
flag when installing the nginx
package already helped us trim down the image size by about 10MB. But we can squeeze even more savings out of it!
Besides avoiding recommended packages, we can also clear out the apt
package manager's cache to further shrink the final Docker image. This cache stores information about available packages, and while it's helpful during installation, it's not needed once the package is installed.
To remove this cache, we can simply add a command to the end of our RUN
instruction:
FROM ubuntu:latest
RUN apt-get update && apt-get install -y --no-install-recommends nginx && rm -rf /var/lib/apt/lists/*
The rm -rf /var/lib/apt/lists/*
part removes the cache directory and its contents. By doing this, we're getting rid of files that take up space but aren't essential for the image to function.
Top comments (1)
Another great post in the series. Thank you very much!
I have a question in this statement "If someone manages to hack into your application running inside the container, they might also gain root access to your whole system". Could you elaborate on this? By the "whole system", do you mean the OS and system inside the container, or the host system that has the Docker?