Build a basic Docker image
![Make a boat](https://static.supergeekery.com/site-assets/_halfWidth/make-a-boat.jpg)
In this post, we’ll dissect a small Docker image I have on Docker Hub: johnfmorton/tree-cli.
It’s not a complex image, but its simplicity is good because it is fairly easy to understand. Once you understand the basics, you can create your own more complex Docker images.
The tree-cli image is based on an official Node image and has one customization: I’ve installed tree-cli from npm, which is a package that allows us to list files in a tree structure. For example, directory listings look like this when using tree-cli.
/app
├── sample-file-1.md
├── sample-file-2.txt
└── subdirectory
├── file-1.txt
└── file-2.jpg
directory: 1 file: 4
It’s so simple, you may wonder why a Docker image would need to be created at all!
If you wanted to install tree-cli
on your own machine, you could use npm and install it locally from the command line and it would probably work just fine.
npm install -g tree-cli
I’m not doing that though because I’m trying to minimize apps I install directly on my computer. For the full story on this method of not installing things, read Dock Life, a post by Andrew Welch. It’s good stuff. You’ll see the tree
command mentioned in there.
The Docker Hub page vs the Github repo page
If you look at the Docker Hub listing for johnfmorton/tree-cli, you will find the actual image, but the thing you’re missing is the Dockerfile which is the document that I used to create the image. The closest you get to that is looking at the layers of the image.
![Docker hub image layers](https://static.supergeekery.com/site-assets/docker-hub-image-layers.png)
A screenshot of the layers from Docker Hub for tree-cli
The layers have a lot of helpful information but looking at the Dockerfile is much easier to understand. I’ve posted the whole project, including the Dockerfile, at Github, https://github.com/johnfmorton/docker-tree-cli.
Let’s take a look at the Dockerfile as a whole and then review it line-by-line.
ARG TAG=16-alpine
FROM node:$TAG
WORKDIR /app
# Install dependencies
RUN set -eux; \
npm install -g tree-cli
# I could have used the /bin/sh shell to give user a prompt by default
# CMD ["/bin/sh"]
# But I prefer to use "tree" to see the directory structure
CMD ["/usr/local/bin/tree"]
Reviewing the Dockerfile
I told you it was short.
Let’s start at the very beginning :musical_score: and review the instructions on each line of the Dockerfile in order to be explicit about what’s happening here.
ARG line
ARG TAG=16-alpine
ARG
is an argument, i.e. a variable we’re creating with the name TAG
. You might have guessed that. The Docker documentation says “the ARG
instruction defines a variable that users can pass at build-time to the builder with the docker build command using the --build-arg <varname>=<value>
flag.”
In other words, we’re going to create an argument, TAG
, that may be useful later when we build this image. If you don’t use it, the variable will contain the string “16-alpine” as the default.
FROM line
FROM node:$TAG
FROM
, according to the docs, “sets the Base Image for subsequent instructions.” The base image is our starting point. There are many base images, official and unofficial, available on Docker Hub. Official images are, according to the docs, “a curated set of Docker repositories hosted on Docker Hub”. Check out the official base image page at Docker Hub and you’ll see many options: Ubuntu Linux, python, MySQL, Nginx, alpine, and more. You can confidently use these images because “Docker, Inc. sponsors a dedicated team that is responsible for reviewing and publishing all content in the Docker Official Images.” (link)
There are even more unofficial images as well. For example, the tree-cli
image we’re going over is an “unofficial” image.
We need a node environment to run tree-cli
because it’s a node module, and we’re going to use an official Node base image called “16-alpine”, which includes node version 16. I like that it’s an official image. The term “alpine”, when it comes to Docker, means that it’s a small, minimal image, based on Linux, “security, simplicity and resource efficiency”.
What if you wanted to use “17-alpine” instead? We’ll get to that when we build the actual image. (Spoiler alert: we’ll use the TAG
mentioned earlier.)
WORKDIR line
The WORKDIR, as the docs say, “sets the working directory for any RUN, CMD, ENTRYPOINT, COPY and ADD instructions that follow it in the Dockerfile.”
The line in our Dockerfile, WORKDIR /app
, means we are setting the directory /app
as the base directory from which the rest of the commands we issue will be run. To be clear, this is insde the container that a directory called /app
will exist.
RUN line
Let’s refer to the documentation again. “The RUN instruction will execute any commands in a new layer on top of the current image and commit the results.” That means we’re going to run a command as we’re building this Docker image. This will customize the base image by doing something to it. In our case, we want to add tree-cli
to that base image. Before we do that though, we’ve got this set -eux;
thing to deal with. What’s that?
We’re not going to reference the Docker documentation for the set
command. We’re going to reference documentation for bash
for set
.
Confused as to why we’re not referencing Docker documentation? At this point in the process, imagine you’ve got your terminal open in this brand new, clean Linux machine. You’re at the bash command line so that’s our current frame of reference. Let’s see what the bash documentation says about set
.
set allows you to change the values of shell options and set the positional parameters, or to display the names and values of shell variables.
So we’re going to set some options for the bash command (i.e. an npm install command). We’re setting 3 options, e
, u
, and r
. We’re combining them all together with -eur;
. Let’s see what each option does.
-e
: exit immediately if a command returns a non-zero status-u
: treat unset variables and parameters other than the special parameters ‘@’ or ‘*’ as an error-x
: print a trace of simple commands
In short, we’re trying to prevent errors and print out as much helpful info to the terminal as we can when we execute our command.
Finally, we’ve ended this line with the ;
and then \
. The semicolon is just basically ending the set command. The backslash is a sort of “new line” command that let’s us execute additional commands without creating a new layer in the Docker image. . There are more best practices like this in the Docker docs, but basically, we’re doing this to keep our Docker image as small as possible.
The ‘npm install ‑g tree-cli’ line
We’re finally installing something on our Docker image. Since we’re being verbose here though, let’s talk about how we knew to use this exact command. We’re going to look at the npm page for tree-cli
, specifically where it mentions installing the software.
npm install -g tree-cli
Comment lines in the Dockerfile + CMD line
I’m going to treat the rest of the file as a single line. The lines that begin with a #
are comments. They are here for reference only, so let’s talk about the CMD
command since it’s the next active line in the Dockerfile. Let’s go back again to the Dockerfile documentation and see what it says.
If you visit that link, there is quite a bit of information there. Here are the important bits for our image.
The main purpose of a CMD is to provide defaults for an executing container.
I hear you! What do those words mean?
Let’s imagine ourselves just a few minutes into the future when you’ve got a completed Docker image that does just one thing: it can list out the directory using the tree
command because it has tree-cli
installed on it. That’s basically all it can do.
So, what do we want the default command to be when it’s run? I think we should run the “tree” command. The CMD
lets us set the default command to run if nothing else is specified when a user tries to use our Docker image.
CMD ["/usr/local/bin/tree"]
If you’re wondering why it says /usr/local/bin/
before tree
, think about the npm install process. The npm install
command installs programs into the /usr/local/bin
directory, so we’re just pointing to where tree
lives on our little itty bitty Linux box.
Optionally, as you can see from my comments in the Dockerfile, we could have defaulted to just showing a bash prompt. I chose not to do that but left the comment tag in because it was a useful reference for me when I look at the file.
# I could have used the /bin/sh shell to give the user a prompt by default
# CMD ["/bin/sh"]
Building the image locally
In case you’re playing along on your computer, let’s make sure we’ve got everything aligned.
A directory on your computer contains the Dockerfile
we’ve been working on above. The filename is just Dockerfile
. It’s not Dockerfile.txt
or dockerfile
or anything else. From within the terminal, you have navigated into that directory. You can confirm this by typing the ls
command and you’ll see the Dockerfile
in there. Good?
We will next use use the build
command. Here is the URL for the documentation on the build command. There is a lot of information there. We’re only going to touch on two things though. The basics of the build command and the tag flag.
First, what is the build command?
The docker build command builds Docker images from a Dockerfile and a “context”. A build’s context is the set of files located in the specified PATH or URL.
Next, what is the -t
flag? It’s how we set a tag. In this case, I don’t think the documentation is exactly clear to a new user.
Create a tag TARGET_IMAGE that refers to SOURCE_IMAG
To paraphrase that, we’re going to think of a tag as a human-readable name that refers to the Docker image we’re going to create. We’ll use the tag testingtreeimage
, but you could also have called it anything you wanted. For my image that you can find on Docker Hub, I’m using the tag name johnfmorton/tree-cli:latest
. Let’s stick with testingtreeimage
for now. (BTW, you can give multiple names to the same image. I can be both “James” and “Jimmy”, if you want.)
What is context?
Before we run the build command, we need to talk about “context”. Let’s see what Understand build context from the docs says.
When you issue a docker build command, the current working directory is called the build context. The Dockerfile is assumed to be located here by default, but you can specify a different location with the file flag (-f). Regardless of where the Dockerfile actually lives, all recursive contents of files and directories in the current directory are sent to the Docker daemon as the build context.
In the build command, we’re about to issue, we use .
to specify our context is the current directory we are issuing the build command from. We’ve also used the correct default name for our Dockerfile already, so no -f
is needed.
Here’s the command we’ll run. It says “Docker, using the image function, build an image, tagging it with the word “testingtreeimage”, using the “.” context, i.e. this current directory, with the default file, i.e. Dockerfile.”
docker image build -t testingtreeimage .
About the TAG
Optionally, here’s where we can use the TAG
from the first line of the Dockerfile. We can pass in an alternative value for TAG
when we build this image to use the 17-alpine node image instead of the 16-alpine node image we defined in our Dockerfile.
docker image build --build-arg TAG=17-alpine -t testingtreeimage .
The default we have in the Dockerfile works fine in this case, but to be complete, I wanted to be sure to cover it here.
If everything goes according to plan in building your image, you should see something like the following.
docker image build -t testingtreeimage .
[+] Building 0.1s (7/7) FINISHED
=> [internal] load build definition from Dockerfile 0.0s
=> => transferring dockerfile: 37B 0.0s
=> [internal] load .dockerignore 0.0s
=> => transferring context: 2B 0.0s
=> [internal] load metadata for docker.io/library/node:16-alpine 0.0s
=> [1/3] FROM docker.io/library/node:16-alpine 0.0s
=> CACHED [2/3] WORKDIR /app 0.0s
=> CACHED [3/3] RUN set -eux; npm install -g tree-cli 0.0s
=> exporting to image 0.0s
=> => exporting layers 0.0s
=> => writing image sha256:fcb052eeb981e7c30f7d95809c78f9b1840c3c67610d39990b5eac2f8b7c5e53 0.0s
=> => naming to docker.io/library/testingtreeimage 0.0s
Listing Docker images
If you built the image successfully, you may wonder where it’s at. We can list the Docker images we have on our machine, docker image ls
, but if you’ve been using other images already, it’s easier to spot this new image by using | grep
and passing in a string which lets us narrow down the results. (We’ll just use part of the tag, not the whole thing. Save your typing strength!)
docker image ls | grep 'testing'
You should see something like this:
docker image ls | grep 'testing'
testingtreeimage latest fcb052eeb981 13 days ago 114MB
Use your new image
Let’s run a container based on our testingtreeimage.
In the container run
command we will use the -v
flag. As you can see from the documentation, the -v
option is to “bind mount a volume.” Basically, we’ll bind our current directory (using the environment variable $PWD, which print out our current working directory) to our container’s /app
directory. This means the tree
command will see the files in our directory and list them. We’re also using --rm
to remove the container when we’re done listing the files.
Open up a new terminal window then run the following command. It will execute the default CMD we baked into the image inside this directory.
docker container run -v "$PWD":/app --rm testingtreeimage
You should see a list of the files in your current directory. Now try passing in a flag to show two levels of files. We must override the default command in our image and pass in tree
plus the flag -l 2
.
docker container run -v "$PWD":/app --rm testingtreeimage tree -l 2
By the way, I’ve updated the CMD
in the live Docker image at johnfmorton/tree-cli to show 2 levels by default. Here’s a link to that commit showing the update in Github.
Where to go from here
Now that you know how to build a basic image, you can experiment with building your own custom images. Try to customize this one by changing the default CMD and rebuilding the image. You can also explore tagging your image with your Dockerhub name push it to your own repo for the world to use.
If you’ve made it this far into the post and wondered about the boat design in the header, check out the post on building it.
Good luck!