When creating a Docker Image you might want to make it easily accessible to you and the world. Most of us have been used to GitHub to share code and, nowadays, DockerHub to make our Docker images publicly available.

We used to be able to connect our GitHub and DockerHub accounts and have DockerHub listen to changed in our code to rebuild and publish the new Docker images. Unfortunately they have changed their model. To do this nowadays a premium subscription is required.

We can still rebuild and push all the changes from our local system to DockerHub but this is tiresome and makes it a bit more complicated to keep an eye on the history of our image. Luckily we can use GitHub actions to do those tasks for us when we push changes to the repository.

Setting up the GitHub Repository

Let's start by setting up a Git repository and connecting it to our GitHub account to upload all the data. First in the computer terminal we need to create a directory and initialize it as a Git repository.

mkdir calculate-pi-image 
cd calculate-pi-image 
git init

Next we need to log in to the GitHub account and start a new repository, we can call it whatever we want, let's say calculate-pi.

In the terminal we can now connect our local directory to our remote GitHub repository.

git remote add origin git@github.com:DennisdeBest/calculate-pi.git
git branch -M main

Let's add a basic Dockerfile and push it to the remote repository.

echo "FROM alpine:3.15" > Dockerfile
git add . && git commit -m "Init"
git push --set-upstream origin main

Now this file is available on the public GitHub repository, but it is not yet a built Docker image and not at all available on DockerHub yet.

Creating a DockerHub account

We now need to set up a DockerHub account. We need to set, and keep in mind, the DockerID. This will be the base of the name that will be needed for other people to get our images. We can now build our Docker images locally and push them to DockerHub. So let's build our basic image. The image needs to get a name with [dockerId]/[image-name]:[tag], In my case this will be debst/calculate-pi:latest

docker build -f Dockerfile . -t debst/calculate-pi:latest

We can now push this image to make it available on our DockerHub account. First we need to make sure we are logged in. It will ask for our Docker ID and password.

docker login

We can than push our image.

docker push debst/calculate-pi:latest

This now works and the image is available. However, these are quite a few actions that we will have to go through on every update. Let's make GitHub do this for us every time we push new updates to the repository.

Connecting GitHub Actions to DockerHub

As I mentioned at the start of this article, it used to be easy to tell DockerHub to keep an eye on any changes in the public GitHub repository and rebuild and publish the image. As this is no longer possible we will put GitHub in our shoes and build, tag and push our images when changes are pushed.

Actions

To get this to work we need to set up GitHub Actions. This will trigger commands to be launched automatically.

These actions are defined in a YAML file within our repository. We will put this inside the .github/workflows directory.

mkdir -p .github/workflows && touch .github/workflows/build-image.yaml

The first thing that will need to be defined in this file is the trigger, when do we need GitHub to do some work for us. Will it be on every change in our repository or do we want to be more specific.

For the Docker images I like to have my tags as versions, so I will want my GitHub to look into the changes when I push a new Git tag that looks like 0.0.1, 1.0.0, 1.2.0, ...

       β”‚ File: calculate-pi-image/.github/workflows/build-image.yaml
───────┼──────────────────────────────────────────────────────────────────────────────
   6   β”‚ name: Publish Docker image
   7   β”‚ 
   8   β”‚ on:
   9   β”‚   push:
  10   β”‚     tags:
  11   β”‚       - '*.*.*'

I will give it a name and declare in the on parameter when it needs to do stuff. I want it to get triggered when I push a tag that looks something like *.*.*

If it was still up to us to do the work we would need to do the following things :

  • Get the files for our image
  • Connect to our DockerHub account
  • Get the correct name and tags we want for our image
  • Build the image, tag it and push it to DockerHub

Therefor we will have to set up steps in our workflow to tell him what to do.

  13   β”‚ jobs:
  14   β”‚   push_to_registry:
  15   β”‚     name: Push Docker image to Docker Hub
  16   β”‚     runs-on: ubuntu-latest
  17   β”‚     steps:

We also need to give it a name and tell it what system to run on to do the work.

Get the files for our image

If we were on a new computer and needed to get all the files to work on them, we would go to GitHub and download them.

  17   β”‚     steps:
  18   β”‚       - name: Check out the repo
  19   β”‚         uses: actions/checkout@v2

Here the uses parameter will tell GitHub to go look for the Checkout action. This is a shared action that is configured to get the code and make it available for the next steps that will get triggered.

Connect to our DockerHub account

  21   β”‚       - name: Log in to Docker Hub
  22   β”‚         uses: docker/login-action@v1
  23   β”‚         with:
  24   β”‚           username: ${{ secrets.DOCKERHUB_USERNAME }}
  25   β”‚           password: ${{ secrets.DOCKERHUB_PASSWORD }}

Here we will get the docker/login-action. As the previous action it will have all the functionality we need under the hood, but it will not be able to log in to our account without our DockerHub username and password. This is why we need to pass it some parameters in the with key. The problem here is that the GitHub repository is public so we do not want to put our credentials inside a file that is within this repository. With GitHub we can set up secrets in our repository and access them in our actions.

On the settings page of the repository, at the bottom of the left-hand menu we can see secrets and, underneath, actions. When we go into this submenu we can add a new repository secret. Here we can give it a name like, DOCKERHUB_USERNAME and the value that is our Docker ID. Next we can add one more for the password. Even we will not be able to see the values that we have just set, we can then only update or delete them.

To access these in our action yaml file when can go get them by wrapping the value with braces, a dollar sign and adding secrets.[the name we have just set] like ${{ secrets.DOCKERHUB_USERNAME }}.

Next we want it to think about the name and the tags of the image.

Name and tags

We want our DockerHub tags to match the git tags that get pushed to the repository and to make sure the latest version also get the latest tag. Here we can use the docker/metadata-action. It will need some configuration.

  27   β”‚       - name: Extract metadata (tags, labels) for Docker
  28   β”‚         id: meta
  29   β”‚         uses: docker/metadata-action@v3
  30   β”‚         with:
  31   β”‚           images: debst/calculate-pi
  32   β”‚           tags: |
  33   β”‚             type=semver,pattern={{version}}

First we need to set the name of the image we want this to get pushed to on our DockerHub, it needs to be [Docker ID]/[image name]. We will also tell it how to tag the image, this is the information at the tags key.

You can find the different configurations on the actions' github readme. We want to get numbered version tags and update the latest tag so this is the configuration we will be going for.

Build and push the image

Last step, now that we are logged in to DockerHub and have all the files and information to build and tag our image we will do that and push it !

  35   β”‚       - name: Build and push Docker image
  36   β”‚         uses: docker/build-push-action@v2
  37   β”‚         with:
  38   β”‚           context: .
  39   β”‚           file: Dockerfile
  40   β”‚           push: true
  41   β”‚           tags: ${{ steps.meta.outputs.tags }}

We can use the docker/build-push-action. Here we will give it a bit of configuration :

  • The context of the build of our image, nothing special here just the current directory in which the build will be run
  • The name of the file that contains our build configuration
  • Whether it should push
  • The tags it will need to push, here we get the output of our previous step ${{ steps.meta.outputs.tags }}

Those are all the steps to get GitHub to do what we have done at the beginning. Now let's make it do it's work.

Tagging updates

Now we have the .github/workflow/build-image.yaml file we can update the code, add a first tag and see if everything works.

git add .
git commit -m "Init workflow"
git tag -a 0.0.1 -m "Init workflow"
git push 
git push --tags

If we go into our DockerHub account we should now see the image calculate-pi with a new tag 0.0.1 and an updated latest tag.

This is already great but the image doesn't do anything. We, actually, want it to calculate Π and return the value. Why not at the same time add the time it took our computer to get the result. We can add and entrypoint.sh file in our repository, copy it in our Dockerfile and make it run when the image is run.

#!/usr/bin/env sh

echo "scale=10;4*a(1)" > /pi_expression
time bc -l /pi_expression

Add it to the Dockerfile

FROM alpine:3.15

COPY entrypoint.sh /usr/bin/entrypoint
RUN chmod +x /usr/bin/entrypoint

ENTRYPOINT ["entrypoint"]

Now update the repository with a new tag.

git add .
git commit -m "Add the pi calculator"
git tag -a 0.1.0 -m "Add the pi calculator"
git push 
git push --tags

The new tag is now 0.1.0 as we finally now have the image do something. Once the actions are done and the new images uploaded to DockerHub anyone can now run this image.

docker run --rm debst/calculate-pi:0.1.0 
Unable to find image 'debst/calculate-pi:0.1.0' locally
0.1.0: Pulling from debst/calculate-pi
df9b9388f04a: Pull complete 
fdfddb275d83: Pull complete 
9da7f07aea67: Pull complete 
Digest: sha256:0e49cef1c65d34e69a2a10564cbc22150915b51df6ec04030509c4b3eade271b
Status: Downloaded newer image for debst/calculate-pi:0.1.0
3.1415926532
real    0m 0.00s
user    0m 0.00s
sys     0m 0.00s

We can update it one more time to let the user set the amount of digits it wants the container to return once it has been run. For that we need to update our entrypoint.

#!/usr/bin/env sh

DIGITS=10

if [ -n "$1" ]; then
    DIGITS=$1
fi

echo "scale=$DIGITS;4*a(1)" > /pi_expression
time bc -l /pi_expression

Now add, commit, tag as 0.1.1 and push.

git add .
git commit -m "Make the output digits variable"
git tag -a 0.1.1 -m "Make the output digits variable"
git push 
git push --tags

If we now run the previous version the parameter will not be taken into account, but it will be for our new 0.1.1 version.

docker run --rm debst/calculate-pi:0.1.0 500              
3.1415926532
real    0m 0.00s
user    0m 0.00s
sys     0m 0.00s

docker run --rm debst/calculate-pi:0.1.1 500
Unable to find image 'debst/calculate-pi:0.1.1' locally
0.1.1: Pulling from debst/calculate-pi
df9b9388f04a: Already exists 
c51b9c849ca8: Pull complete 
a7b553074023: Pull complete 
Digest: sha256:412966a92ff929ea652053724b95b58797538a0bba91d79064c08ba100876b7a
Status: Downloaded newer image for debst/calculate-pi:0.1.1
3.141592653589793238462643383279502884197169399375105820974944592307\
81640628620899862803482534211706798214808651328230664709384460955058\
22317253594081284811174502841027019385211055596446229489549303819644\
28810975665933446128475648233786783165271201909145648566923460348610\
45432664821339360726024914127372458700660631558817488152092096282925\
40917153643678925903600113305305488204665213841469519415116094330572\
70365759591953092186117381932611793105118548074462379962749567351885\
75272489122793818301194912
real    0m 0.22s
user    0m 0.21s
sys     0m 0.01s

Automate all the things !

There you have it, even though DockerHub removed the feature to auto build, tag and update the images we can still automate this thanks to the GitHub actions. I hope this will help you to create and share your Docker images !