In CircleCI, developers can freely combine arbitrary images like building blocks to create CI container environments. For example, CircleCI supports Docker natively. You can build, push and deploy the application as a Docker image. CircleCI builds the Docker image using Docker in a Docker container.

In this post, I will describe how to build a Docker image in CircleCI and push the image to Docker Hub, Docker’s official cloud-based registry for Docker images.

Prerequisites

Setting up the pipeline configuration

In your project’s root directory, create a config.yml in a directory called .circleci:

version: 2.1
jobs:
  build:
    docker:
      - image: cimg/base:2022.09
        auth:
          username: $DOCKERHUB_USERNAME
          password: $DOCKERHUB_PASSWORD
    steps:
      - checkout
      - setup_remote_docker
      - restore_cache:
          keys:
            - v1-{{ .Branch }}
          paths:
            - /caches/app.tar
      - run:
          name: Load Docker image layer cache
          command: |
            set +o pipefail
            docker load -i /caches/app.tar | true
      - run:
          name: Run tests
          command: |
            docker-compose -f ./docker-compose.test.yml up
      - run:
          name: Build and Push application Docker image
          command: |
            TAG=0.1.$CIRCLE_BUILD_NUM
            docker build -t $DOCKERHUB_USERNAME/circleci-docker-example:$TAG .
            echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USERNAME --password-stdin
            docker push $DOCKERHUB_USERNAME/circleci-docker-example:$TAG

Depending on the project, some of the details may change. If you want to try these steps for yourself, I prepared a sample project that you can use this config file with.

The project is written in Node.js using Express and simply returns “Hello World”, tested with Jest and supertest.

Following each section, I will provide an explanation of how it works.

Building the top-level structure

version: 2
jobs:
  build:
    docker: ...
    steps: ...

In CircleCI, you can define the steps of each job, including repository checkout and cache-related processing. This gives you great freedom, not only for the CI container environment but also for the steps. You can find all the details in the official documentation.

Using the Docker executor

docker:
  - image: cimg/base:2022.09

In this section, you define a cimg/base Docker image. You can use this pre-built convenience image to set up the continuous integration build environment, Ubuntu in our case. This image is also very useful as a base for any custom Docker images.

Your goal is to have a Docker image that installs Docker and has Git. These requirements can be satisfied by using cimg/base. As stated in the official documentation, it contains all you need to run most builds, including Git, Docker, Docker Compose, and much more.

Accessing the Docker image registry

docker:
  - image: ...
    auth:
      username: $DOCKERHUB_USERNAME
      password: $DOCKERHUB_PASSWORD

The auth field is used to specify the required credentials to access the Docker Hub image registry. These credentials are pulled in as environment variables, which you can set either at the project level or in an organization-wide context. To learn more, read the documentation on setting an environment variable.

Checkout

steps:
  - checkout

The first step, checkout, is a special step to check out the source code; this will be downloaded to the working directory in CircleCI.

Set up remote Docker

- setup_remote_docker

This step helps you avoid the Docker-in-Docker problem. In fact, you’re setting up an environment that is isolated from the CI or primary container, then using the remote host’s Docker Engine.

Building and caching Docker images

- restore_cache:
    keys:
      - v1-{{ .Branch }}
    paths:
      - /caches/app.tar
- run:
    name: Load Docker image layer cache
    command: |
      set +o pipefail
      docker load -i /caches/app.tar | true

This section covers the following:

  • When there’s a cache suffixed with v1-{{ <branch name> }}, CircleCI will restore your directory to /caches/app.tar, which is the Docker image file from the previous build.

  • When /caches/app.tar exists, Docker will load it, allowing you to reuse images from previous builds.

Docker layer caching

is included in every CircleCI plan, including the free plan. You can refer to the sample project’s build history to see how much speed improvement you can get by caching the image layer.

Running tests

- run:
    name: Run tests
    command: |
      docker-compose -f ./docker-compose.test.yml up

For this project I am running the tests with Docker Compose, and the tests are run only in the application container. If you need a database container, it is easy to set one up using Docker Compose.

Pushing the Docker image

- run:
    name: Build and Push application Docker image
    command: |
      TAG=0.1.$CIRCLE_BUILD_NUM
      docker build -t $DOCKERHUB_USERNAME/circleci-docker-example:$TAG .
      echo $DOCKERHUB_PASSWORD | docker login -u $DOCKERHUB_USERNAME --password-stdin
      docker push $DOCKERHUB_USERNAME/circleci-docker-example:$TAG

This is where the application’s Docker image is built and pushed to Docker Hub. In a chronological order, it:

  • Specifies the command to build the Docker image.
  • Reads a password from CircleCI environment variable and passed it to the docker login command for authentication purposes.
  • Pushes the image to Docker Hub.

Conclusion

CircleCI allows you to use the remote Docker engine to build Docker images. Even though the improvement in build speed was minor in my case, I still learned that caching the Docker image layer can be done. Depending on the project, build speed improvements could be much greater.

Read more: