How to Build a CI/CD Pipeline with Go, GitHub Actions and Docker

This tutorial will show you how to setup a CI/CD pipeline using GitHub Actions. The pipeline will test, build and publish a Go app to Docker Hub when changes are pushed to a GitHub repository.

Overview

Here is an overview of what’s in this guide:

Okay, let’s get started by creating the Go app.

Step 1: Create Go Project

First let’s create a simple Go program that prints out Hello World along with the version number of our app.

Create a folder to store our files:

mkdir ~/go-pipeline-demo
cd ~/go-pipeline-demo

Create main.go and main_test.go

touch main.go
touch main_test.go

Add the following code to main.go

package mainimport "fmt"var version = "dev"func main() {
fmt.Printf("Version: %s\n", version)
fmt.Println(hello())
}
func hello() string {
return "Hello Worl"
}

Note: the version variable is assigned the string dev and the string returned by the hello function is intentionally wrong so that our tests fail. Later we will see how the GitHub action can be configured to automatically replace the version string at build time.

Add the following code to main_test.go

package mainimport "testing"func TestHello(t *testing.T) {
want := "Hello World"
got := hello()
if want != got {
t.Fatalf("want %s, got %s\n", want, got)
}
}

Make sure our tests run and fail by running:

go test
Output of go test showing FAIL

It fails because we made the return value of the hello function wrong on purpose so we can test the CI/CD pipeline catches errors.

We can also test the app runs by running:

go run main.go
Output of go run main.go

Notice it prints out dev for the version. When we run the release version from the published image on Docker Hub, we will see the version number that was tagged when pushing a release to the GitHub repo.

Step 2: Create GitHub Repository

Login to your GitHub account and create a new repository. For the following example I have created a new public repo called go-pipeline-demo.

Create new GitHub Repository to store our Go code

Step 3: Assign Docker Hub Credentials as Secrets

For this step you will need to login to your Docker Hub account and generate an access token. Once you’ve done that, navigate to the Settings screen of the GitHub repository then click on Secrets.

Settings Screen of GitHub Repository

Click on New repository secret, give it a name of DOCKER_USERNAME and the value of your Docker account ID.

Add Docker ID to GitHub Secrets

Click on New repository secret, give it a name of DOCKER_ACCESS_TOKEN and the value of the access token you generated before.

Add Docker Access Token to GitHub Secrets

You should now have two secrets with the names DOCKER_USERNAME and DOCKER_ACCESS_TOKEN.

GitHub Repository Secrets Screen Containing Docker Hub Credentials

The GitHub workflow will use these credentials to authenticate with Docker Hub when pushing the image to the container registry.

Step 4: Create GitHub Workflow

We are now ready to create the GitHub workflow. Create a folder inside your repository called .github/workflows and add a file called push.yml.

mkdir -p .github/workflows
touch .github/workflows/push.yml

Add the following config to the push.yml file:

name: go-pipeline-demo
on: push
jobs:
test:
runs-on: ubuntu-latest
if: github.ref == 'refs/heads/main' || startsWith(github.ref, 'refs/tags')
steps:
- uses: actions/checkout@v2
- name: Run Unit Tests
run: go test
deploy:
runs-on: ubuntu-latest
needs: test
if: startsWith(github.ref, 'refs/tags')
steps:
- name: Extract Version
id: version_step
run: |
echo "##[set-output name=version;]VERSION=${GITHUB_REF#$"refs/tags/v"}"
echo "##[set-output name=version_tag;]$GITHUB_REPOSITORY:${GITHUB_REF#$"refs/tags/v"}"
echo "##[set-output name=latest_tag;]$GITHUB_REPOSITORY:latest"
- name: Set up QEMU
uses: docker/setup-qemu-action@v1
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v1
- name: Login to DockerHub
uses: docker/login-action@v1
with:
username: ${{ secrets.DOCKER_USERNAME }}
password: ${{ secrets.DOCKER_ACCESS_TOKEN }}
- name: Build and push
id: docker_build
uses: docker/build-push-action@v2
with:
push: true
tags: |
${{steps.version_step.outputs.version_tag}}
${{steps.version_step.outputs.latest_tag}}
build-args: |
${{steps.version_step.outputs.version}}

The workflow config is quite simple. You will notice there are two jobs, one called test and one called deploy. The test job has one step that uses an if to make sure it only runs when changes are pushed to the main branch or a tag is pushed.

The deploy job requires the test job to run first so that if the tests fail, the Docker image will not be built. If the test job does pass, the remaining steps will run. The first step extracts the version number from a git tag that has the following format v1.0.0.

The next two steps setup the environment so that Docker images can be built. Finally, a step is run that signs into your Docker Hub account using the credentials stored in GitHub Secrets, then the Docker image is built and pushed to Docker Hub.

Step 5: Create Dockerfile

Now we need to create a Dockerfile for the GitHub action to use when building the image. Create a file called Dockerfile at the root of the repo.

touch Dockerfile

Add the following to the Dockerfile:

FROM golang:1.15.5-buster AS builder
ARG VERSION=dev
WORKDIR /go/src/app
COPY main.go .
RUN go build -o main -ldflags=-X=main.version=${VERSION} main.go
FROM debian:buster-slim
COPY --from=builder /go/src/app/main /go/bin/main
ENV PATH="/go/bin:${PATH}"
CMD ["main"]

The config above uses a multi-stage build process to build the Go app then copy it to a slim Debian image. If you want to make the image smaller, you can change it to alpine or scratch.

Let’s test the Dockerfile by building it with the following command:

docker build -t go-pipeline-demo:dev .
Output of docker build command

Now run the image and see if it prints the incorrect string Hello Worl:

docker run --rm go-pipeline-demo:dev
Output of docker run command

We can assign a version to the image by using --build-arg VERSION=1.0.0 while building the image. For example:

docker build -t go-pipeline-demo:1.0.0 . --build-arg VERSION=1.0.0

The reason this works, is because we use -ldflags to modify the version variable at compile time. The --build-arg assigns the version to VERSION and is used in the following line:

RUN go build -o main -ldflags=-X=main.version=${VERSION} main.go

Okay, we are now ready to push our code to the GitHub repository.

Step 6: Push Code to Main

Now that we know our project builds and we have created a repository, we are ready to push our code to the main branch. Let’s initialise Git, commit our changes, add the origin, and push the changes by running the following commands (replace the repo URL with your own):

git init
git add .
git commit -m "first commit"
git remote add origin git@github.com:tonymackay/go-pipeline-demo
git branch -M main
git push -u origin main

Step 7: Check Build Failed

Go to your GitHub repository and click Actions. We should see the GitHub action running. Since we are pushing to the main branch, it will run the test job and it should fail.

GitHub Action failing after a broken code is pushed to main

Step 8: Fix Code so Test Passes

Let’s fix the broken test.

Open main.go and modify the return value of the hello function so that it returns Hello World.

Commit and push the changes.

git add .
git commit - "Fix hello function return value"
git push

The GitHub action should run again and now the test will pass.

GitHub Action running test job after fixed commit was pushed to main

Step 9: Push Tag

Now that we know the test passes, let’s push a release tag. We will tag this version as v1.0.0

git tag v1.0.0
git push --tags

After pushing the tags, the GitHub action will run again and this time it will build the Docker image and push it to your Docker Hub repository.

GitHub Action running test and deploy jobs after a tag was pushed
Docker image containing Go app has been published to Docker Hub

Step 10: Pull Docker Image and Test

Let’s test the release version of the app by running the following command (replace the Docker account ID with your own):

docker run --rm tonymackay/go-pipeline-demo:latest
Output of Go app running from Docker release image

As you can see the version number is now 1.0.0 and Hello World is printed out.

Conclusion

That’s it. You now have a CI/CD pipeline that can be used to automatically test, build and publish your Go app to a container registry.

We also looked at how to update the version info without manually modifying code. This is one less process to worry about when releasing versions of your app.

Automating tests and builds is a good way to reduce mistakes during the development/release process. Having multiple versions tagged and pushed to Docker also allows the ops team to roll out new updates fast, or rollback changes if they need to.

I write tutorials for Cloud Architects, DevOps Engineers and System Administrators at https://graspingtech.com

Get the Medium app

A button that says 'Download on the App Store', and if clicked it will lead you to the iOS App store
A button that says 'Get it on, Google Play', and if clicked it will lead you to the Google Play store