DEV Community

Cover image for Dockerfile Best Practices Explained - Interlude Post 2
Kostas Kalafatis
Kostas Kalafatis

Posted on

Dockerfile Best Practices Explained - Interlude Post 2

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:

  1. The --user (or -u) flag: You can use this flag when running a container with docker run to specify a different user ID (UID) or username to use inside the container.
  2. 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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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
Enter fullscreen mode Exit fullscreen mode

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/*
Enter fullscreen mode Exit fullscreen mode

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)

Collapse
 
deadreyo profile image
Ahmed Atwa

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?