We may have seen many Docker images on Docker Hub and wondered who created them. Well, they were created by developers like us. In this lesson, we learn everything we need to know about creating Docker images.


Creating Docker images with docker build

Let’s take the example above further to a real-world scenario. In the example, we package a real-life application into a container. We use an existing source code of TodoMVC.

TodoMVC is a popular to-do application built in many frameworks.

First, we clone the course repository (https://github.com/abiodunjames/docker-lessons.git). We can do this with the git clone command:

git clone https://github.com/abiodunjames/docker-lessons.git
$ cd path-to-docker-lessons/todomvc/exercise

If we’re able to clone successfully, we should have the docker-lessons directory created with all the source code for this course in our local environment. We’re interested in the VueJs implementation, so we navigate to the todomvc/exercise directory.

To preview the application, we can open the index.html in our browser to see how it works.

To begin the containerization process, we create a file named Dockerfile in the exercise directory:

touch Dockerfile

The base image

The first instruction to define in a Dockerfile is where we wish to start from. Docker images usually start from a base image, but it’s also possible to start from scratch.

However, a starting point can’t be absent. When working with Dockerfiles, we must specify where we want to start—either from scratch or from a base image.

As stated earlier, a base image or parent image is where our image is based. It’s our starting point. It could be an Ubuntu OS, Redhat, MySQL, Redis, Node Server, and so on.

We’ll use node:12 as the base image here, so we add the code below to the Dockerfile we created:

FROM node:12

Set the work directory and install dependencies

To set a working directory and install a dependency the application needs, we add the code below to the Dockerfile:

WORKDIR /app
RUN yarn global add http-server

We use WORKDIR to set the working directory of the container. WORKDIR is equivalent to creating a directory and navigating to that directory at the same time. When we use WORKDIR /some/directory, we use mkdir /some/directory and cd /some/directory. Every instruction we execute afterward will be executed in that directory.

Secondly, we need http-server to be able to serve static content, so we use the RUN command to install it globally.

The RUN command executes a command in a shell. Adding a RUN instruction creates a new layer and executes the command on the base image.

Copy the source code

One of the fundamentals of containers is that a container packages everything required to run an application, including the source code. This is what makes the COPY command important. It allows us to copy files and dependencies an application needs to run into a container.

The Docker COPY command is written as COPY <source>... <destination>:

COPY package*.json ./
RUN yarn 

We use the COPY command to copy package.json files from the host to a path in the container and leverage the RUN command to install the application dependencies in the container.

Now, we have all the dependencies required by the application in the container. We now copy the source code:

COPY . .

Expose a port

EXPOSE 8080

The EXPOSE instruction informs Docker that a container is listening on a specified port. The EXPOSE instruction is completely optional.

It doesn’t affect our Docker image’s ability to build or run. However, it’s always good to include it since it serves as a way of documenting and of informing the container’s user about which ports are to be published.

In this example, exposing port 8080 indicates that the application will be accessible on port 8080 when run.

Docker CMD

The CMD command instructs Docker on how to execute the application we packaged in the image. The CMD instruction follows the CMD [“command”, “argument1”, “argument2”,...] format:

CMD [ "http-server", "/app" ]

We can see a CMD instruction as a way of telling Docker which instruction to run when the container starts. Usually, this is where we should specify how to start the application. CMD will always be run.

Putting everything together

At the end, our Dockerfile should look like this:

FROM node:12
## make the 'app' folder the current working directory
WORKDIR /app
## install simple HTTP server for serving static content
RUN yarn global add http-server
## copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./
## install project dependencies
RUN yarn
## copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .
EXPOSE 8080
CMD [ "http-server", "/app" ]

Ignoring files

There’s an important concept we have to internalize while working with Docker images. We should always keep our Docker image as lean as possible. This means that we should only package what our applications require to run.

In reality, the source code typically includes additional files and directories such as .git,.idea,.vscode, travis.yml, logs, and so on. These are necessary for our development workflow, but they won’t prevent our application from running. They don’t belong to the Docker container.

It’s best not to include them in our image. That’s what .dockerignore is for. It prevents such files and directories from entering a Docker build.

We create a file called .dockerignore in the root folder with the following content:

.git
.gitignore
node_modules
npm-debug.log
Dockerfile*
README.md
LICENSE
.vscode

Build a Docker image

With a Dockerfile written, we can build the image using the following command:

docker build .

Try it out:

Terminal 1
Terminal
Loading...

Ensure we’re in the docker-lessons/todomvc/solution directory before executing the docker build command.

With the image built successfully, we check it by running the command below:

docker image ls

If we run the command above, the output should look similar to this:

REPOSITORY             TAG       IMAGE ID       CREATED         SIZE
<none>   <none>    9eb760c667c0   3 minutes ago   884MB

In the output above, the repository and latest column are represented as <none>. When we have numerous images, it can be difficult to distinguish between them. Docker allows us to tag our images with friendly names of our choice. This is called tagging.

Tag the images

Tagging Docker images is important for many reasons:

  • It’ll help us version our Docker images.

  • When we tag a Docker image, we’re able to push it to a Docker registry.

  • Tagging our Docker images properly will drive clarity and is important in CI/CD automation.

We use the following code to tag an image at build time:

$ docker build -t yourusername/repository:image_tag .

Let’s rebuild and tag the Todo application:

$ docker build -t abiodunjames/todomvc .

We should get an output similar to this:

[+] Building 3.4s (11/11) FINISHED                                                        
 => [internal] load build definition from Dockerfile                                 0.3s
 => => transferring dockerfile: 37B                                                 
 => => writing image sha256:9eb760c667c0fce2ff680a88b27621e1d42162d4bfbc308b24de575  0.0s
 => => naming to docker.io/abiodunjames/todomvc   

Congratulations! We just created a Docker image that can run on any Docker host.

Run a Docker image

Let’s run the image by executing the following command:

$ docker run -p8080:8080 abiodunjames/todomvc
Starting up http-server, serving /app
Available on:
  http://127.0.0.1:8080
  http://172.17.0.2:8080
Hit CTRL-C to stop the server

The command is pretty simple. We supply the -p flag to specify the port on the host machine to which the container’s port should be mapped. The container port is the port the application listens to in the container.

To run the container in a detached mode, we can supply the argument -d:

$ docker run -p8080:8080 abiodunjames/todomvc

Try it out

FROM node:12

# make the 'app' folder the current working directory
WORKDIR /app

# install simple http server for serving static content
RUN yarn global add http-server

# copy both 'package.json' and 'package-lock.json' (if available)
COPY package*.json ./

# install project dependencies
RUN yarn 

# copy project files and folders to the current working directory (i.e. 'app' folder)
COPY . .

EXPOSE 8080

CMD [ "http-server", "/app" ]
Try it out