The complete guide to getting started with building Docker images

kylegalbraith

Kyle Galbraith

Posted on September 27, 2023

The complete guide to getting started with building Docker images

Packaging applications and services into containers has been around for a while. Docker was a technology that came out of another idea called DotCloud in 2013. So, even the Docker containers we know and love today are a decade old. But it's important to remember that the underlying technology of a Docker container is even older.

What is Docker?

The underlying technologies backing Docker containers are low-level Linux kernel components like cgroups, namespaces, and a union-capable file system like OverlayFS. These technologies are what allow Docker containers to be so lightweight and portable. Combined, they allow a single Linux VM to run multiple containers.

Installing Docker

To get started with Docker, you need to install it first. Depending on what you're running containers on, there are multiple ways to do that. Here are three Docker installation guides:

  1. Install Docker for Linux
  2. Install Docker for Mac
  3. Install Docker for Windows

Each Docker installation guide ultimately installs Docker Desktop and configures the Docker engine on the given operating system.

What is a Docker image vs. a Docker container?

When getting started with Docker, a common question is, what is the difference between a Docker image and a Docker container? A Docker image is a series of layers stacked on each other that form the dependencies and source code needed to run your application. During a Docker image build, all those layers get packaged together to produce a final Docker image.

A Docker container is a runnable instance of a Docker image. You can run multiple containers with the same image to run multiple copies of your application or service.

A Docker image is the source code and dependencies packaged together, and the Docker container is the running instance of that image.

So, what is a Dockerfile?

A Dockerfile is a file that contains instructions for how to build a Docker image. It's a text file that includes a series of instructions that are executed in order to build a Docker image. The Dockerfile is the recipe that produces our Docker image.

As we will see in a minute, a Dockerfile is executed from top to bottom during a given docker image build. Instructions are invoked in order, and each instruction generally maps to an image layer. Those layers, stacked on top of each other one after the other, form our final Docker image.

Dockerfile instructions and what they do

Several different instructions can be used in a Dockerfile. Each instruction is a command that is executed during the build process. The most common instructions are:

Instruction What it does
FROM Defines a new build stage and sets the base image for that stage
RUN Executes any commands it is given in a new layer on top of the current image that has been built up to that point
COPY Copies the contents from a source directory to the filesystem at the path passed in to a new layer in the image
ADD A more advanced version of COPY that supports things like local tar extraction and remote URLs
CMD Defines the defaults for when this image is launched as a container, usually includes an executable to invoke, but that's not required
ENTRYPOINT Configures the executables or commands that will run once the container is initialized
USER Sets the user that the container is run under, often used to run containers as non-root
LABEL Adds key-value labels to the image being built; note that labels are passed down from base images
ARG Define build-time only variables that can be used during the Docker image build
ENV Sets environment variables from within the Docker image that can be used during the build process or when the container is run
EXPOSE Defines a port that the container will listen on when the image is run as a container
WORKDIR Sets the working directory for the commands that follow it
VOLUME Creates a mount point with a specific name that is bound to a mounted volume from the underlying host or another container

Building a Docker image

Now that we have a solid foundation, we can build a Docker image and see how a Docker image build works with a sample application. For this example, we will use an example Fastify API that uses TypeScript and pnpm for package management. The example project can be git clone on our GitHub.

After cloning the project, we can run pnpm install and pnpm build from the root of the example to install our dependencies and build our TypeScript source code.

pnpm install && pnpm build
Enter fullscreen mode Exit fullscreen mode

After building the code, we should see a dist directory with our compiled code.

ls dist/
  index.js
  index.js.map
Enter fullscreen mode Exit fullscreen mode

We can now run the example API outside a Docker container to see if it works as expected. We use curl to hit a /health endpoint on that API that returns a simple JSON response.

pnpm start
curl localhost:3000/health
{"alive":true}
Enter fullscreen mode Exit fullscreen mode

Keeping our Docker image size down

Before jumping straight into writing a Dockerfile for our example project, we need to start with a .dockerignore first.

A .dockerignore file tells the build what files and directories to ignore and exclude from the Docker build context when we run docker build. Our project git repositories often contain many files and folders that we don't need in our final image or the build context itself.

node_modules
Dockerfile
.git
.gitignore
dist/**
README.md
Enter fullscreen mode Exit fullscreen mode

This .dockerignore file tells the Docker build to ignore all of these files and directories during the build. These files will be excluded from the Docker build context and thus won't be copied via any COPY or ADD instructions.

Writing a Dockerfile

Now that we have a .dockerignore file, we can write our Dockerfile. For a more advanced Dockerfile that is highly optimized, we can use our best-practice Dockerfile for Node.js & pnpm. The optimized Dockerfile uses multi-stage builds, optimized Docker layer caching, and BuildKit cache mounts to optimize the docker build image process.

Simple example Dockerfile

For this post, we will use a more straightforward example Dockerfile to walk through core concepts.

First, we need a base image for our Docker image to be built from. Since our example project is in Node, an official Node base image like node:20 is an excellent place to start.

FROM node:20
Enter fullscreen mode Exit fullscreen mode

Once we have a base image, we can install our dependencies and build our application. We first enable corepack, an experimental tool for managing versions of package managers. We then copy in our package.json and pnpm-lock.yaml files and install our dependencies. Finally, we copy in our source code and build our application.

RUN corepack enable

COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile
COPY . .
RUN pnpm build
Enter fullscreen mode Exit fullscreen mode

The final thing to do is to set our CMD instruction, which tells the container what to run when it's launched. In our case, we want to run our compiled index.js file, our API.

ENV NODE_ENV production
CMD ["node", "./dist/index.js"]
Enter fullscreen mode Exit fullscreen mode

Note: This Dockerfile is not optimized for size or build performance. It's meant to be an example to follow along with. For an optimized version that uses multi-stage builds and Docker layer caching, see our best-practice Dockerfile for Node.js & pnpm.

Building our Docker image

Now that we have a Dockerfile, we can start building our Docker image with docker build. We can run this command from the root of our example project. We tag our resulting image with the name fastify-example via the --tag flag.

docker build --tag fastify-example .
Enter fullscreen mode Exit fullscreen mode

If we run the docker images command, we should see our new image in our list of container images.

docker images
REPOSITORY                                TAG       IMAGE ID       CREATED          SIZE
fastify-example                           latest    7e3f51733ddd   8 seconds ago    1.18GB
Enter fullscreen mode Exit fullscreen mode

Running our Docker image

Now that we have built our Docker image with the fastify-example tag, we can try running it locally. We can run a Docker container of our image via the docker run command.

We run our Docker container with the -p (i.e., --port) flag to specify that we want to forward traffic from port 8080 on our host machine to port 3000 in the container because our API is listening on this. We also run our container with the -d flag, which tells the Docker Daemon to run the container detached in the background.

docker run -p 8080:3000 -d fastify-example
Enter fullscreen mode Exit fullscreen mode

We can verify our container is running via the docker ps command.

docker ps
CONTAINER ID   IMAGE             COMMAND                  CREATED         STATUS         PORTS                    NAMES
5595944ea42b   fastify-example   "docker-entrypoint.s…"   3 seconds ago   Up 2 seconds   0.0.0.0:8080->3000/tcp   peaceful_brahmagupta
Enter fullscreen mode Exit fullscreen mode

We can also verify our Docker container is up and working by hitting the /health endpoint with curl.

curl localhost:8080/health
{"alive":true}
Enter fullscreen mode Exit fullscreen mode

We can also use other Docker CLI commands like docker logs to see the logs from our container. Note that the logs command expects the container ID or name, not the image name. From our example above, the name of our container is peaceful_brahmagupta.

docker logs peaceful_brahmagupta
{"level":30,"time":1695637221083,"pid":1,"hostname":"6e8107cd9149","msg":"Server listening at http://0.0.0.0:3000"}
Enter fullscreen mode Exit fullscreen mode

We can use the docker inspect command to get a low-level description of our container. The JSON output can be helpful for debugging and troubleshooting.

docker inspect peaceful_brahmagupta
Enter fullscreen mode Exit fullscreen mode

Finally, we can call docker stop to stop our container with a graceful shutdown, or we can call docker kill to kill our container, which will terminate it immediately.

Pushing our Docker image to a registry

When we build a Docker image locally or via our remote builders in Depot, the container image, by default, is kept on the machine that ran the Docker image build. When we want to run the Docker image locally, as we saw in the earlier step, the image staying on our machine is excellent.

But, most of the time, we want to push our image to a Docker container registry so we can share it with other developers, deploy it to our production environments, etc.

There are numerous container registries like Docker Hub, Amazon Elastic Container Registry (ECR), GCP Artifact Registry, and GitHub Container Registry. For this example, we will assume we are using GitHub Container Registry.

To push to a Docker container registry, we generally need to call docker login to authenticate to our registry. For GitHub Container Registry, we can use the ghcr.io hostname, our GitHub username, and a personal access token (PAT) to authenticate.

docker login ghcr.io -u GITHUB_USERNAME --password GITHUB_PAT
> Login succeeded
Enter fullscreen mode Exit fullscreen mode

After logging into our container registry, we can build our image with a tag that includes the registry hostname, our GitHub username, and the image name. We also specify the --push flag, which will push our image to the registry we've tagged it with.

docker build -t ghcr.io/GITHUB_USERNAME/fastify-example:latest --push .
Enter fullscreen mode Exit fullscreen mode

Alternatively, we can use docker tag and docker push to push an image we've built locally to a registry.

docker tag fastify-example ghcr.io/GITHUB_USERNAME/fastify-example:latest
Enter fullscreen mode Exit fullscreen mode

This tags our fastify-example Docker image with ghcr.io/GITHUB_USERNAME/fastify-example:latest, and then we can push it to our registry.

docker push ghcr.io/GITHUB_USERNAME/fastify-example:latest
Enter fullscreen mode Exit fullscreen mode

Conclusion

We've covered in this post how to get started with Docker and build a Docker image from a Dockerfile. We've also covered how to run a Docker container from that image and how to push that image to a container registry. All of these are handy to know when it comes to working with containers locally and in production.

With Depot, we build the Docker image up to 20x faster and provide critical insights about how to rewrite your Dockerfile to build faster, leverage caching, and more. We remove the need to think about the artifacts Docker produces, allowing you to focus on writing your own code and getting it into production faster.

You can sign up for an account and get your first 60 minutes of build time free. If you have questions comments or want to chat more about containers, check out our Community Discord.

πŸ’– πŸ’ͺ πŸ™… 🚩
kylegalbraith
Kyle Galbraith

Posted on September 27, 2023

Join Our Newsletter. No Spam, Only the good stuff.

Sign up to receive the latest update from our blog.

Related