We recently announced the ability to use a Docker linter on build in our recent lint & build blog post via hadolint and Semgrep.
Running a Dockerfile linter on a Docker image we want to build can allow us to follow some of the best practices around writing efficient Docker images. Efficient could mean faster builds or smaller image sizes.
This post covers the ten most common Docker linting issues we've seen flowing through Depot to date. We expect these to change over time, but hopefully they can give everyone a good starting point for improving their Dockerfiles. We'll cover each issue, why it's a problem, and how to fix it.
How to lint a Dockerfile
With Depot, we make use of two Docker linters, hadolint and a set of Dockerfile linter rules that Semgrep has written to make a bit smarter.
To lint a Dockerfile on-demand with Depot, we can pass the --lint flag during a build. The Docker linter runs before the build.
Of course, we can also run hadolint ourselves locally without Depot with our own specific rules and config file. Or even use the hadolint Dockerfile linter UI. To run hadolint locally you can either install it via brew or use the Docker image and pipe your Dockerfile into it:
hadolint Dockerfile
# or use the Docker image
docker run --rm -i ghcr.io/hadolint/hadolint < Dockerfile1. Multiple consecutive RUN instructions
Also known as lint error DL3059 from hadolint.
This is the most common issue we see with Dockerfiles flowing through Depot. It's present in nearly 30% of all Dockerfiles we've seen. The problem is that multiple RUN instructions are in a row that could be condensed. For example:
RUN download_a_really_big_file
RUN remove_the_really_big_fileIt's helpful to know how Docker layer caching works to understand why this might be problematic. In short, each new RUN statement in a Dockerfile results in a new layer in the final image.
In this example, we create a new layer when we download the big file and another layer when we remove it. Both layers will be present in the final image. So, the final image will contain the big file in the first layer, making the final image larger than it needs to be.
However, DL3059 can also be problematic if we use two different RUN statements to install packages. For example:
RUN fetch_package_registry_list
RUN install_some_packageThe first RUN statement will fetch the package registry list in this example. The second RUN statement will install the package. But if the package registry list changes between the first and second RUN statements, then the package registry list will be out of date when we install the package.
Solution to DL3059
When working with large files that we add and remove during a docker build, combining those operations into one atomic RUN statement is helpful.
RUN download_a_really_big_file && \
remove_the_really_big_fileThis reduces the final image size by removing the intermediate layer that contains the big file as we download and remove it in the same RUN statement. Note that this can have cache implications if you combine RUN statements with things that can be cached with things that frequently invalidate the cache. In those situations, you likely want to keep the portion that can be cached in its own RUN statement.
For the package registry example, we want to combine the fetch registry list with the install package into one RUN statement.
RUN fetch_package_registry_list && \
install_some_packageThis ensures that the package registry list is updated when we install the package instead of potentially being outdated.
2. Pin versions during apt-get install
A more controversial Dockerfile linting issue is DL3008 from hadolint. This issue is also present in 30% of all Dockerfiles. The problem arises when not pinning versions during apt-get install. For example:
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-packageWhen you don't version pin, you're not forcing the docker build to verify it has a specific version and thus the required packages you may need. This can lead to unexpected behavior when you build your Dockerfile or run the resulting image if you inadvertently installed a newer version of a package than you expected.
Solution to DL3008
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-package=1.2.*By pinning the version of some-package, the build is forced to retrieve the particular version. This allows you to build up guarantees about the packages you're installing in your Dockerfile and the dependencies of those packages.
The reason it's controversial is because version pinning runs the risk of needing to catch up on security updates. For example, suppose you pin a package version with a security vulnerability. In that case, you risk not getting your security update when you build your Dockerfile until you change the version to a new one. This is why it's essential to understand the packages you're installing and the security implications of pinning versions.
3. Use --no-install-recommends to avoid installing unnecessary packages
Another widespread linter error is DL3015, installing unnecessary packages with apt-get. This is present in 22% of all Dockerfiles. The issue arises when we're not using the --no-install-recommends flag during apt-get install. For example:
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-packageWhen you don't use the --no-install-recommends flag, you install all the recommended packages for the package and the package itself. Potentially increasing the final size of your Docker image by installing packages you don't need.
Soltuion to DL3015
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-package --no-install-recommendsThe solution is to pass the flag --no-install-recommends to apt-get install. This will prevent the installation of recommended packages and reduce the final size of your container image. It's essential to understand the recommended packages for the packages you're installing to ensure you're getting all the dependencies.
4. Avoid using the cache directory when using pip install
Docker layer caching comes in again when we're talking about pip install during a Docker build. Hadolint error DL3042 is present in 18% of all Dockerfiles. The issue arises when we're not telling pip install not to use a cache directory in our Dockerfile. For example:
FROM python:3.11
RUN pip3 install mysql-connector-pythonWhen you don't tell pip install not to use a cache directory, it will install the package and keep a cache directory for that package, which creates an unnecessary cache entry for every package you've installed via pip in that layer. When you have lots of packages, this can increase your final Docker image size.
Solution to DL3042
FROM python:3.11
RUN pip3 install --no-cache-dir mysql-connector-pythonWe don't need a cache directory for our pip packages because we don't need to reinstall packages when building a Docker image. The Docker layer cache can be used instead. Turning off the cache directory makes our final image smaller.
5. Remove the apt-get lists after installing packages
As we explored in our post around reducing Docker image sizes, keeping container image sizes down often returns to the actual docker build process. Hadolint error DL3009 is present in 16% of all Dockerfiles. The issue arises when we're not removing the apt-get lists after installing packages. For example:
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-package --no-install-recommendsOur earlier example for DL3015, shown here, can be optimized further to keep the final image size down. By not cleaning up the apt-get cache, it's written into the layer for that RUN statement. We are taking up valuable space in our final image.
Solution to DL3009
FROM ubuntu:22.04
RUN apt-get update && \
apt-get install -y some-package --no-install-recommends && \
apt-get clean && \
rm -rf /var/lib/apt/lists/*Here, we are combining the installation of some-package with the clean-up of the apt-get cache so that installing and clean-up happen in one atomic RUN statement. This keeps the final image size down by removing the apt-get cache from the final image and doesn't introduce another layer into the final image.
6. Make use of WORKDIR instead of RUN cd some-path
Another common Dockerfile linter issue is DL3003, using RUN cd instead of the WORKDIR statement. This is present in 14% of all Dockerfiles. Here is a typical example:
FROM ubuntu:22.04
RUN cd /usr/src/app && git clone git@github.com:depot/some-repo.gitEach RUN statement executes inside its own shell, and most commands can work with absolute paths.
Solution to DL3003
FROM ubuntu:22.04
WORKDIR /usr/src/app
RUN git clone git@github.com:depot/some-repo.gitWhen changing directories, you can use the WORKDIR statement, which spawns the shell in your specified directory. The only exception is when you need to do something inside the subshell; in that scenario, you still need to use cd.
7. Pin versions when installing packages via pip
Like DL3008, the Dockerfile linter issue DL3013 is the same idea but applied to pip install instead of apt-get install. This is present in 13% of all Dockerfiles. Here is a typical example:
FROM python:3.11
RUN pip3 install --no-cache-dir mysql-connector-pythonWhen you don't version pin, you're not forcing the docker build to verify it has a specific version and thus the required packages you may need. As we saw in DL3008, this can have unexpected behavior if we install a different version than what we originally installed when we created the Dockerfile.
Solution to DL3013
FROM python:3.11
RUN pip3 install --no-cache-dir mysql-connector-python==8.1.0By pinning the version of mysql-connector-python, the docker build is forced to retrieve the particular version regardless of what may be in the Docker layer cache.
8. Use JSON notation for CMD and ENTRYPOINT arguments
This Dockerfile lint error, DL3025, comes down to correctness when running the image. It's present in 12% of all Dockerfiles. Here are typical examples for both statements where this comes up:
FROM ubuntu:22.04
ENTRYPOINT foo run-serverFROM ubuntu:22.04
CMD foo run-serverWhen we don't use JSON notation for CMD and ENTRYPOINT arguments, the executables referenced won't receive signals from the OS correctly. This is particularly relevant when talking about how to signal to a running container that it is being shut down (i.e., a SIGTERM).
Solution to DL3025
FROM ubuntu:22.04
ENTRYPOINT ["foo", "run-server"]FROM ubuntu:22.04
CMD ["foo", "run-server"]By using JSON notation, the executable will be the containers PID 1 and, therefore, receive signals from the OS. Two additional things to note about this notation:
-
CMDdoesn't process environment variables in shell form (i.e.,$FOO_BAR) because of the side effect of howsh -cis used as the default entry point. So, we must handle environment variables ourselves outside theCMDstatement. -
The
CMDstatement is parsed as a JSON array, so we must use double quotes ("") instead of single quotes('') to correctly pass our arguments.
9. Use apt-get or apt-cache instead of the user facing apt
The command, apt, is meant to be an end-user tool and not to be used in Dockerfile RUN statements. So, DL3027 flags this Dockerfile lint error when we use apt instead of apt-get or apt-cache. This is present in 9% of all Dockerfiles. Here is a typical example:
FROM ubuntu:22.04
RUN apt install -y some-package=1.2.*Solution to DL3027
FROM ubuntu:22.04
RUN apt-get install -y some-package=1.2.*The interface of apt is not guaranteed across versions by Linux distributions. So it's better to use apt-get or apt-cache, which are more stable.
10. Pin versions when installing packages via apk add
As we've seen in DL3008 and DL3013, pinning versions is also important for apk add in Alpine-based Dockerfiles. This is present in 8% of all Dockerfiles. Here is a typical example:
FROM alpine:3.7
RUN apk --no-cache add some-packageSolution to DL3018
FROM alpine:3.7
RUN apk --no-cache add some-package=~1.2.3The rationale is the same: version pinning forces the docker build to fetch the pinned version regardless of what may be in the Docker layer cache. An important thing to note for Alpine-based images is that we are using partial pinning here via the ~ syntax. We can pin to a specific version via some-package=1.2.3, but this will fail the build if this package is removed.
Conclusion
In this post, we looked at the top 10 most common Dockerfile linting issues we're seeing as builds are flowing through Depot. As we saw, they can vary in severity and impact. But they all have the potential to improve your Dockerfiles and your builds. Each issue comes with its own set of pros and cons.
For example, pinning versions can guarantee a specific state when building Docker images but have the downside of potentially missing security updates. Or using --no-install-recommends can avoid making your image bigger for dependencies you don't need or use. But it can also mean you miss a dependency that you need.
This post has given you some ideas on improving your Dockerfiles and your builds via linting. If you want to learn more about how Depot can help you improve your Dockerfiles on-demand, check out our recent post on our Docker linter.
If you're looking to make your Docker image build process faster either for native Intel or Arm images, sign up for an account and give things a try. We make it easy to run your first build with either docker build or depot build via our quickstart guide.

