Baeldung Pro – Ops – NPI EA (cat = Baeldung on Ops)
announcement - icon

Learn through the super-clean Baeldung Pro experience:

>> Membership and Baeldung Pro.

No ads, dark-mode and 6 months free of IntelliJ Idea Ultimate to start with.

Partner – Orkes – NPI EA (cat=Kubernetes)
announcement - icon

Modern software architecture is often broken. Slow delivery leads to missed opportunities, innovation is stalled due to architectural complexities, and engineering resources are exceedingly expensive.

Orkes is the leading workflow orchestration platform built to enable teams to transform the way they develop, connect, and deploy applications, microservices, AI agents, and more.

With Orkes Conductor managed through Orkes Cloud, developers can focus on building mission critical applications without worrying about infrastructure maintenance to meet goals and, simply put, taking new products live faster and reducing total cost of ownership.

Try a 14-Day Free Trial of Orkes Conductor today.

1. Introduction

The Docker suite is a staple for container application development, delivery, and deployment. Within its flexible structure, the Docker system provides many ways to define and configure containers. One of the main ways to ensure a Docker application has a consistent behavior is to use a Dockerfile when building its container image. However, the syntax and options of this feature have been evolving.

In this tutorial, we look at a specific Dockerfile option and how advanced features might cause problems depending on the setup and environment. First, we briefly refresh our knowledge about the Dockerfile concept. After that, we go over the evolution of its syntax. Next, we delve into ways to introduce build-time data within the container image. Finally, we talk about feature support inside a Dockerfile and during a build.

We tested the code in this tutorial on Debian 12 (Bookworm) with GNU Bash 5.2.15. Unless otherwise specified, it should work in most POSIX-compliant environments.

2. Dockerfile

Before delving into the specifics, let’s briefly discuss the idea, structure, and mechanics behind a Dockerfile, one of the main ways to create and define container images.

A Dockerfile is a regular text file with the recipe for a Docker container image. Usually, this means we set the so-called base image file, on top of which we make customizations:

  • package installation
  • general setup and configuration
  • storage allocation
  • startup command definition

For clarity and reference, let’s define a minimalistic Dockerfile:

FROM debian:latest
CMD ["bash"]

Here, we base the new image on the latest version of the debian image. After that, we define the initial command to run when starting the container as bash.

Of course, there are many other options, configurations, and possibilities to choose from when writing a Dockerfile. Yet, only some of them are more common and standardized.

3. Evolution of the Dockerfile

Earlier versions of a Dockerfile only supported basic instructions such as the aforementioned FROM and CMD, along with some others:

  • RUN: execute commands with respect to the current image
  • EXPOSE: open port ranges
  • WORKDIR: set the current working directory
  • COPY: copy local files to the image
  • ADD: either copy local files or pull files from external sources

Naturally, these are only some examples of the foundation that Dockerfile started with.

Later, healthcheck options and multi-stage builds drastically improved the ease with which developers could ensure the stability of containers.

Notably, BuildKit was introduced as a faster, feature-rich, user-friendly, and overall better engine to perform builds based on a Dockerfile. One of its features is a way to specify build-time mounts.

4. Dockerfile Mounts

Docker mounts are a way to enable access to data outside the Docker image itself.

Notably, Docker mounts via the runtime –volume (-v) flag of the docker build command aren’t supported by the Dockerfile syntax.

4.1. Basics

Essentially, a Docker application developer can link dynamic data sources from any outside environment. On the other hand, users and deployment teams may change the contents of data sources without going through the image or its components.

Further, since Docker images are ephemeral, e.g., they get reset on each restart, Dockerfile mounts are also a way to provide persistence.

4.2. Syntax

To employ Dockerfile mounts, we use the RUN command with the –mount option:

RUN --mount=[type=TYPE],option=<value>[...]

This way, we can create a special mount type with particular options.

The default mount type is bind. However, different mount types are available to support special cases.

4.3. Mount Types

Let’s list supported Dockerfile mount types:

  • type=bind: local file or directory binding (usually for debugging)
  • type=cache: cache directories across builds to increase cache hits
  • type=secret: secure secrets storage for sensitive data such as passwords, keys, and tokens
  • type=ssh: SSH agent forwarding

Now, let’s see the –mount option in action.

4.4. Simple Example

Let’s assume we want to mount a given local file or directory:

# syntax=docker/dockerfile:1.4
FROM debian:latest
CMD ["bash"]
RUN --mount=type=bind,source=/tmp/file666,target=/file1

In this case, we create a basic bind mount from the local file /tmp/file666 to the path /file1 within the container image.

4.5. Caching

Now, we can move on to more complex scenarios.

So, let’s look at type=cache:

# syntax=docker/dockerfile:1.4
FROM node:20-slim
WORKDIR /xpp
COPY package*.json ./
RUN --mount=type=cache,target=/root/npmcache
    npm ci
COPY . .
CMD ["node", "main.js"]

In this case, we base the image on the slim modification of node version 20. After setting the WORKDIR working directory, we COPY the package*.json files responsible for the dependencies. Then, the RUN command uses /root/npmcache in the container as the cache, while the actual files are written to a location outside the container, managed by Docker.

4.6. Secrets

Often, container image builds require credentials. In similar cases, the type=secret mount can be indispensable.

For instance, let’s create a secret mount in a new Dockerfile:

# syntax=docker/dockerfile:1.4
FROM debian:latest
RUN --mount=type=secret,id=xecret
    sh -c 'read xecret < /run/secrets/xecret; echo "The secret is: $xecret"'

Here, we just add the xecret secret, which becomes automatically accessible within /run/secrets/ and only exists during the build instead of being available in the image afterward.

After that, we use the –secret parameters with the specific id and src to populate the secret at build time:

$ DOCKER_BUILDKIT=1 docker build
  --secret id=xecret,src=xecret.txt
  -t seccon .

Thus, xecret.txt becomes the file that /run/secrets/xecret points to while building, passing information without risk.

4.7. SSH Mount

When building images, access to protected external sources is often required to make a container actually useful and portable.

For instance, application code is rarely stored locally and might be hosted within a repository or even a basic SSH server:

# syntax=docker/dockerfile:1.4
FROM debian:latest
RUN apt-get update && apt-get install -y git openssh-client
RUN --mount=type=ssh
    git clone [email protected]:example-user/example-repo.git /xpp

In this Dockerfile, we use git to clone a repository after adding a type=ssh mount. Notably, we use the local package manager to ensure the git and openssh-client packages are in place.

At this point, let’s ensure the SSH agent is running and the proper key is added:

$ eval "$(ssh-agent -s)"
Agent pid 66600
$ ssh-add $HOME/.ssh/id_rsa

Once we have the proper build recipe and SSH agent, the –ssh command-line parameter of docker build should be all we need during the build process:

$ docker build --ssh default -t sshcon .

In this case, we employ the default agent setup, but we could use another name or even a custom socket path.

5. Dockerfile Syntax and Build Engine

Notably, in each instance, depending on the context, we used one of two special additions:

  • # syntax=docker/dockerfile:1.4
  • [export ]DOCKER_BUILDKIT=1

Let’s see what each one means and other alternatives we can leverage.

5.1. Dockerfile Syntax Indicator

The # syntax line is a kind of shebang or preprocessing instruction within a Dockerfile:

# syntax=docker/dockerfile:1.4

It tells the build engine to allow and employ the features of a particular syntax version. Without a syntax specifier, we might encounter an error when using more advanced constructs, since the default version is commonly 1.0:

Error response from daemon: Dockerfile parse error line #: Unknown flag: mount

Of course, this line only pertains to and is mandatory for the Dockerfile.

There are other means to instruct Docker which build engine to use and enable the option.

5.2. DOCKER_BUILDKIT Environment Variable

Similarly, we might get an error if we don’t set the DOCKER_BUILDKIT environment variable to 1 before a build. Exported or just prepended to the line, Docker references the variable to know whether it should use the BuildKit (non-0) instead of the classic build engine (0).

Yet, there is another way to indicate that without runtime specifications.

5.3. buildkit Configuration Option

Lastly, we can modify the config.json Docker configuration file to set BuildKit as the permanent build engine:

$ cat $HOME/.docker/config.json
[...]
{
  "features": {
    "buildkit": true
  }
}
[...]

This way, we don’t need to specify DOCKER_BUILDKIT=1 in the environment. However, we still need to add the syntax specifier in each Dockerfile.

6. Summary

In this article, we understood what a Dockerfile is, how it evolved, and ways to use special mount types. Furthermore, we saw how special build features can be employed.

In conclusion, although there are alternatives, Dockerfile is still one of the main ways to build images, even when builds require advanced functionality.