Automatically build Docker images with GitHub Actions

Automatically build Docker images with GitHub Actions

Introduction
Setting up a Runner

Structure of a GitHub Action

1. name

2. on

3. jobs

GitHub Action to build Docker Images

Process & preconditions
1. Triggers
2. Git Checkout
3. Split the branch name and get the version number
4. Build the image
5. Tag the image
6. Push the images to the registry
7. Remove all build data from the runner
8. All together

Conclusions

Introduction

When releasing a new version of a web app, this was the process, which might sound familiar to you:

I merged everything into the master branch
SSH into the deployment server
git fetch
git checkout
git pull
Build docker images (this took loooong and once the web app was big enough, it would fail)
Then I would have to build the image in my computer or another specific server to avoid the production server crashing
Push that to a registry
Go back to the server and pull from the registry
Realize you did not update an environment variable
AGHHHJJJ!!!

Here’s how I do it now:

Create a new staging/ or release/ branch from my develop branch. i.e. release/1.0

That’s it!

Setting up a Runner

Definition: A runner is an instance that runs whatever you set in your GitHub action. From making it print Hello World to building and deploying apps to making you coffee (seriously).

GitHub gives you 2 options:

Use one of their runners for free up to X minutes every month and then pay for it
Spin up a self hosted runner and use that for free

If you go with the first option skip this, otherwise continue reading.

Since I already have a home lab and and setting up a runner is quite easy, self hosted the easy route for me to save a few bucks. You can also do this with an old computer or even a raspberry pi you have laying around.

If you want to learn more about self hosted runners look into GitHub’s documentation.

This is what you need to set up a self hosted runner:

Select the OS you want your runner to be. I’m a fan of Ubuntu and I can pretty much do everything I need with it, so I went with a Linux runner.
Make sure you have any necessary packages installed in the runner. For my use case I would need to install Docker first.
Go to your GitHub repo, then Settings

In the left panel hit Actions and then Runners

Select New self-hosted Runner

Select the OS you want to use and architecture

Follow the terminal commands displayed below your selection to finish setting up your runner. I do not want to post the commands here since GitHub updates them every once in a while, so just follow their instructions over there.
Once you finish setting the runner up in your Linux machine you should see it in the Runners page from before and you’re ready to use it!

Structure of a GitHub Action

GitHub actions are defined in .yml files.

They live under .github/workflows/ in your repository and will be live once you push them to your master branch.

You can either set them up directly with GitHub’s web interface or with your favorite editor and push them to your master branch.

A (simple) GitHub action has 3 main parts:

1. name

Easy, this is just the name you want to give the action to track it later on.

name: My GitHub action name

2. on

This is what you want to trigger the action on. The most common case is to tricker an action on a push to a specific branch or opening a pull request. But there are many triggers.

You can see documentation on all the triggers here:
https://docs.github.com/en/actions/using-workflows/events-that-trigger-workflows

Here’s an example code to:

Be able to trigger it manually. For more information, see “Manually running a workflow.”
Be triggered when I create or push to a release/ branch

on:
# Trigger the action manually from the UI
workflow_dispatch:
# Trigger the action when pushing to certain branches
push:
branches:
my-branch-name’
my-other-branch-name’

3. jobs

These are the individual series of steps you want to execute. Each job has a series of sequential steps and every job can be run asynchronously.

Differences between Jobs and Steps

Jobs are individual, asynchronous tasks. This means that if you have several tasks that can happen asynchronously (at the same time) (i.e.: deploying an app and uploading new documentation) you can put them in separate jobs. If you have several runners you can execute several jobs at the same time.

Steps are actions that happen synchronously. If you have tasks that need to run one after the other (i.e.: Building a docker image and then pushing it to a Docker registry) set them up in steps instead of jobs.

Sample code:

jobs:
build_docker_images:
# Job name that shows in the GitHub UI
name: Build Docker Images
# Runner to use
runs-on: self-hosted

steps:
name: Checkout
uses: actions/checkout@v3

You can set up as many jobs and steps as you like in a single action file.

GitHub Action to build Docker Images

In this section I will show you how to build a docker image from a docker-compose.yml file and push it to my private Docker Registry. All by just creating or pushing to a specific branch.

I will create a future article showing how to deploy an app once it’s build. Please comment below if you want to be notified.

Process & preconditions

After checking that your self-hosted runner is available and that the structure of a GitHub action is clear, here are my preconditions:

When I push to the release/… branch I want to get the version name (i.e.: release/1.5 → 1.5), then build the Docker image and tag it with that version (i.e.: my-image:1.5).

After this, I want to push the image to a private Docker Registry I self-host and remove all data from the runner.

Here’s the process:

Trigger the action manually or with a push to a release/… branch
Checkout the branch
Split the branch name as to only get the version number
Build the Docker image
Once the image is built, tag it with the version number and the docker registry
Push the image to the registry
Remove all build data from the runner

1. Triggers

I want not only for the action to be triggered on push to release branches but also manually.

name: Build release Docker image

on:
# Trigger the action manually from the UI
workflow_dispatch:
# Trigger the action when I create or push a `release/**` branch
push:
branches:
release/**’

The two * mean that the action will trigger on any branch name that starts with release/. i.e.: release/1.3

2. Git Checkout

I will only require that job since all my steps need to be sequential.

GitHub provides a default library with many steps integrating their service.

We first need to checkout the branch that triggered the action:

jobs:
build_docker_images:
# Job name that shows in the GitHub UI
name: Build Docker Images
# Runner to use
runs-on: self-hosted

steps:
name: Checkout
uses: actions/checkout@v3

3. Split the branch name and get the version number

name: Get the release version
# i.e.: release/1.0.0 -> 1.0.0
id: strip-branch-name
run: |
release_version=$(echo “${{ github.ref }}” | sed ‘s/refs/heads/.*///’)
echo “Building release version $release_version”
echo “RELEASE_VERSION=$release_version” >> $GITHUB_ENV
shell: bash

Here I just run some commands in the Ubuntu bash shell:

release_version=$(echo “${{ github.ref }}” | sed ‘s/refs/heads/.*///’): Splits the branch name from the first / using sed

echo “Building release version $release_version”: This just prints out in the GitHub actions UI

echo “RELEASE_VERSION=$release_version” >> $GITHUB_ENV: This sets the RELEASE_VERSION environment variable for this action, to be used in other steps

shell: bash: Indicates what shell this code needs to run in

4. Build the image

name: Build the Docker image
run: docker build . –file Dockerfile –tag my-project:$RELEASE_VERSION

Here we use the RELEASE_VERSION environment variable defined in the previous step.

5. Tag the image

I want to tag the image two ways here:

With the version number tag
With the latest tag

name: Tag the image for the private registry registry.mydomain.com
run: docker tag my-project:$RELEASE_VERSION registry.mydomain.com/my-project:$RELEASE_VERSION

name: Create a latest image as well
run: docker tag my-project:$RELEASE_VERSION registry.mydomain.com/my-project:latest

6. Push the images to the registry

name: Push the Docker image with version number
run: docker push registry.mydomain.com/my-project:$RELEASE_VERSION

name: Push the latest tag
run: docker push registry.mydomain.com/my-project:latest

7. Remove all build data from the runner

This is a step that I do in all of my Actions. This ensures I always have a clean slate.

In this case it consists in removing all the built images.

name: Remove the Docker image with version number
run: docker rmi registry.mydomain.com/my-project:$RELEASE_VERSION

name: Remove the Docker image with latest tag
run: docker rmi registry.mydomain.com/my-project:latest

name: Remove the local image
run: docker rmi my-project:$RELEASE_VERSION

Because I release pretty frequently I don’t prune Docker, so that next time the build happens fast. If you want to remove absolutely all data you can prune the system by:

name: Prune Docker
run: docker system prune -a -f

-a will prune all unused images, not just dangling ones

-f will avoid docker prompting for a user confirmation

8. All together

Here’s the full action code. You can copy and paste it into your YML file and modify it as needed:

name: Build release Docker image

on:
# Trigger the action manually from the UI
workflow_dispatch:
# Trigger the action when I create or push a `release/**` branch
push:
branches:
release/**’

jobs:
build_docker_images:
# Job name that shows in the GitHub UI
name: Build Docker Images
# Runner to use
runs-on: self-hosted

steps:
name: Checkout
uses: actions/checkout@v3

name: Get the release version
# i.e.: release/1.0.0 -> 1.0.0
id: strip-branch-name
run: |
release_version=$(echo “${{ github.ref }}” | sed ‘s/refs/heads/.*///’)
echo “Building release version $release_version”
echo “RELEASE_VERSION=$release_version” >> $GITHUB_ENV
shell: bash

# Build the Docker image
name: Build the Docker image
run: docker build . –file Dockerfile –tag my-project:$RELEASE_VERSION

# Tag the Docker Images
name: Tag the image for the private registry registry.mydomain.com
run: docker tag my-project:$RELEASE_VERSION registry.mydomain.com/my-project:$RELEASE_VERSION

name: Create a latest image as well
run: docker tag my-project:$RELEASE_VERSION registry.mydomain.com/my-project:latest

# Push the images to the registry
name: Push the Docker image with version number
run: docker push registry.mydomain.com/my-project:$RELEASE_VERSION

name: Push the latest tag
run: docker push registry.mydomain.com/my-project:latest

# Remove the local images
name: Remove the Docker image with version number
run: docker rmi registry.mydomain.com/my-project:$RELEASE_VERSION

name: Remove the Docker image with latest tag
run: docker rmi registry.mydomain.com/my-project:latest

name: Remove the local image
run: docker rmi my-project:$RELEASE_VERSION

Conclusions

With this set up you now automatically will have Docker images ready for use in the Registry of your choice.

I will create a future article showing how to automatically deploy an app once it’s build and in a registry. Please comment below if you want to be notified.

Leave a Reply

Your email address will not be published. Required fields are marked *