From Dockerfile to Distroless - Container LabSchritt 4 von 12: Writing your first Dockerfile
Alle Labs

Writing your first Dockerfile

Containers have been around for a long time. In the early 2010s, Docker made it easy to build recipes to create containers. These recipes are called Dockerfiles and even if you're not using Docker as a Container Engine or Runtime, it's a common convention to call such files Dockerfiles.

Now it's time to open your favorite IDE and create a file named Dockerfile in the root of the checked-out repository.

Base Images

One of the major benefits of containers is the reusability and efficiency in terms of disk space usage. This is achieved by using a layered filesystem, which you can imagine as a series of zip files, extracted over each other.

In our case, we will take a pre-created container image representing the Linux distribution and some packages needed to run the Node.js application in our container. This one is stored in a registry (Docker Hub) and we can simply download it.

Info

Although we don't need it in our Lab here, you can always pull an image using the command docker pull <image-name> to download an image into the cache of your machine.

In our Dockerfile, we can use such a base image using the FROM directive, which will fetch the image from the container registry and let us use files, binaries, and libraries from it. Therefore, please open your favorite editor, open your Dockerfile and add the following to it:

FROM debian:bookworm-slim

Image names typically follow this format: registry/namespace/image:tag. For example:

  • debian:bookworm-slim (official image from Docker Hub)

  • ghcr.io/tsclabs-eu/lab-container-basics/learning-app:v1.0 (from GitHub Container Registry as we will see later)

  • if you omit the tag given, "latest" is used to pull or push the image

  • avoid using latest wherever possible. The "latest" tag can change without warning and break reproducibility

  • Container engines have their default registries. When using Docker, this is Docker Hub. Other engines might use different registries.

This is the most simplistic Dockerfile you can create. Although it won't do much more than download the base image, it is possible to build it.

📝Note

The Dockerfile itself is only a description of the build process and only changing the Dockerfile will do nothing, you have to run a build command to get your container built.

Let's start our first container build, so open your shell, navigate to the directory of your repository and run:

docker build -t my-first-container .

This command consists of four parts:

  • The name of the executable (docker)
  • A directive on what to do (build)
  • Options for our build (-t my-first-container). This will tell the build process to name the resulting container image my-first-container.
  • A context (.) that tells the container engine which directory to use as the root of the build. Any local files referenced in the build are looked up relative to this directory.

After executing this command, you should get an output like

[+] Building 41.0s (5/5) FINISHED                                                    

[...]

 => exporting to image                                                                        0.0s
 => => exporting layers                                                                       0.0s
 => => writing image sha256:afb9fee037e00356afc763f5b7a40982fd647c2d35282aa6e807bbf14b10c34f
 => => naming to docker.io/library/my-first-container

When everything finished successfully, the container should be in your local image cache and you should be able to run it:

docker run --name my-first-container my-first-container
Common Issues

If your container doesn't behave as expected:

  • Port already in use?
    Use a different local port: -p 8080:3000

  • Container won't start again?
    Remove it: docker rm learning-tracker

  • Not seeing logs?
    Use docker logs learning-tracker to debug.

  • Unsure what's running?
    Run docker ps -a to list all containers.

You might have noticed that the structure of the docker command is very similar to the build command, you still have the same executable, but a different verb and in this case a mandatory path.

If everything goes well, you should be in your shell again. But why?

Typically, a container runs as long as there is something to do. In the Debian container, there is no long running process in there. Therefore, the command which gets executed (bash) finishes, and therefore the container terminates. You can inspect the state of your container using the command docker ps -a.

CONTAINER ID   IMAGE               COMMAND             STATUS                      PORTS     NAMES
4d23f2b3cd2e   my-first-container  "bash"              Exited (0) 2 minutes ago              my-first-container

You can see that the container is in the state "Exited" and therefore not running anymore. If you want to run the container, but keep its shell open, you can use the -it parameter to run it in interactive mode:

docker run -it --name my-first-container my-first-container

Now, you might experience that Docker shows an error that the container name is already in use. This is because the container is still there, but not running. You can remove it using the command:

docker rm my-first-container

After this, you can run the container again using the command above. Now you should be in a shell inside of the container. You can run commands like ls -la or pwd to inspect the container.

Additional Information

If you want your container to keep running, ensure that the process you're spawning does not terminate

Next, we'll switch to a base image designed for running Node.js services.

Building a Node.js Application

Currently, our container build is not very sophisticated. To create more value, we have to add some more directives to it.

At first, we want to copy files from our repository into the container (keep in mind that the container only has a base image at the beginning, therefore we have to add the files we need). Please open your Dockerfile again and replace the contents with the following lines:

FROM node:22

COPY app /app

This will change the base image to the Node.js-image, but also copy the contents of the local app directory (remember that we use the context .) into the /app directory of the container.

After this, please add the line

WORKDIR /app

into your Dockerfile. This will ensure that all of the commands you are executing are running in the directory we copied in the container before.

Afterward, we have to download our dependencies. Like in every npm-based JavaScript application, we can achieve this using the command npm install, which can be executed in the Dockerfile using

RUN npm install

The RUN directive makes it possible for you to run commands inside of a container. Since the node image includes npm, this works out of the box.

Last but not least, we want to execute the application when the container gets started. For this, we can use the CMD directive to start the npm process:

CMD ["npm", "run", "start"]
CMD vs ENTRYPOINT

In Dockerfiles, CMD specifies the default command, but it can be overridden at runtime. ENTRYPOINT is used when you want to enforce the command and just pass arguments. Most Node.js apps use CMD, as you've done here.

The resulting Dockerfile should look like this:

FROM node:22

COPY app /app

WORKDIR /app

RUN npm install

CMD ["npm", "run", "start"]

Furthermore, we want to ensure that build artifacts from local builds will not get copied inside the container. Therefore, we can create a .dockerignore file that will avoid copying specified files to the container. We want to do this with the app/node_modules folder. Please create a new .dockerignore file in the root of your repository and add the following contents:

app/node_modules

After this, we should be able to build our container.

docker build -t learning-tracker:v0.1 .

This will build and create our container. After this has been finished, you should be able to run the container using the command

docker run --name learning-tracker --rm learning-tracker:v0.1

In this command, the --rm option ensures that the container is removed after it has been stopped, so you don't have to clean up manually. The --name option gives the container a name, which makes it easier to reference later.

Now you should see some output like this:

Server running on http://localhost:3000

Now your fancy service is running, but if you're trying to browse http://localhost:3000, you'll find out that you will not be able to access something, or at least not your service. What could be wrong here?

💡Important

Think about your container as a process that is put into a box. It can not be accessed from the outside and cannot access data from the outside. Resulting, you have to specify at start if you want to forward ports from your host system to the container and if you want to use data from your host system.

With this in mind, we will forward the application port (in our case 3000) to a local port to access our new application. Therefore, we have to add the parameter -p <local-port>:<container-port> to the docker command, similar to:

docker run -p 8888:3000 --name learning-tracker --rm learning-tracker:v0.1

When navigating to http://localhost:8888 in your browser, you should now see the shiny learning-tracking application.

🎉Well Done!

You've successfully created your first container! You've learned how to write a Dockerfile, build a container image, and run it with port forwarding. This is a major milestone in your container journey.