Building and Hosting Docker “Golden” Images with GitHub Actions and GitHub Container Registry

Learn how to securely build and host Docker images with GitHub Actions and the Container Registry, and see why standardized base images are critical for consistency in today's complex container landscape. Dive in now for these essential insights!

Joel Burch

Joel Burch

COO

Docker has fundamentally changed software development, impacting how applications are built and packaged. In parallel, the evolution of CI/CD and DevOps practices has reshaped software building and delivery. GitHub Actions and GitHub Container Registry provide a batteries-included solution for building and hosting Docker container images. However, public container repositories are often targets for compromise and vulnerabilities. Having a standardized base image ensures consistent build environments and app performance.

This article will provide developers with a walkthrough of: building and securing Docker images with GitHub Actions, hosting them with GitHub Container Repository, and distributing them for usage in development environments.

Setting up Docker and GitHub

Before getting started, there’s a few prerequisites it’s important to have set up:

It’s expected that readers will have some familiarity with each of these technologies, and have access to a terminal or command line interface. A high-level understanding of CI/CD pipelines will also be helpful. 

NOTE: An important caveat to consider is that while GitHub offers a fixed quota of free GitHub Actions minutes and storage for artifacts and images, following the steps in this article could potentially lead to charges in your GitHub account if your free quota has already been used up. Divio is not responsible for charges incurred in your account as a result of actions taken. 

The next step is to create a Dockerfile that will be used to build the base image:

# Use the alpine base image to reduce build time and image size 
FROM node:20-alpine

# Update system packages and dependencies 
RUN apk update && apk upgrade

The Node public image already has the Node dependencies and toolchain installed, so it’s simply a matter of making sure the packages and dependencies are up to date before proceeding.

Getting Started with GitHub Actions

GitHub Actions is an automation feature of GitHub that allows users to configure and deploy fully featured CI/CD workflows that integrate with GitHub repositories. GitHub Actions workflows are configured with yaml configuration files that run the gamut from simple to complex, multi-file workflows.

Workflow files have an extensive list of options available for configuring workflows; it’s recommended to spend some time getting familiar with workflow syntax before deploying workflows. For this article, a full workflow configuration will be provided as an example.

Building a Docker Image with GitHub Actions

The workflow for building and pushing will be broken down into three main stages:

  1. Build image with dependencies and any tooling needed.

  2. Scan the image for any potential vulnerabilities and outdated packages.

  3. Pushing the Docker image to the GitHub Container Repository.

GitHub Actions Configuration

GitHub Actions workflows can be configured and enabled simply by placing a yaml configuration file in the following directory of a GitHub repository:

<repository>/.github/workflows/<workflow_name>.yaml

For this example, the workflow configuration will be named “image_build.yaml”.

This part of the workflow will trigger on pushes to the main branch, a push of any tag, and any pull request targeting the main branch. It also enables automated building that will trigger at 6AM everyday to ensure a new image is available, and provides the ability via workflow_dispatch to trigger the build manually via the GitHub Actions UI.

The permissions statement ensures that this workflow has permissions to push images and read and write credentials from environment variables. From there, it’s a standard process of checking out the code, setting up the Docker build environment with buildx, and finally building the Docker image in the GitHub Actions environment.

name: Image Build and Push

on:
  push:
branches: [ main ]
tags: ['*']
  pull_request:
branches: [ main ]
  workflow_dispatch: 
  schedule: - cron: 0 6  * 

permissions: write-all

jobs:
  build-and-push:
runs-on: ubuntu-latest

steps:
   - name: Check out code
     uses: actions/checkout@v4

   - name: Set up Docker Buildx
     uses: docker/setup-buildx-action@v3

   - name: Build image
     run: |
      docker buildx build -t ${{ github.repository }}:${{ github.sha }} -f Dockerfile .

This part of the workflow will use Aqua Security’s Trivy scanner to scan the image for outdated packages, security vulnerabilities, and secrets:

- name: Scan image
     uses: aquasecurity/trivy-action@master
     with:
       image-ref: '${{ github.repository }}:${{ github.sha }}'
       format: 'table'
       exit-code: '1'
       ignore-unfixed: true
       vuln-type: 'os,library'
       severity: 'CRITICAL,HIGH'

Scan results will appear in the workflow logs, and any detected issues will result in the workflow failing before an image is pushed.

This step uses the Docker metadata action to tag the image with the short commit sha, branch name, and the ‘latest’ tag if the PR is merged and the main branch is pushed:

- name: Docker meta
  id: meta
  uses: docker/metadata-action@v5
  with:
    images: ghcr.io/${{ github.repository }}
    flavor: |
      latest=auto
    tags: |
      type=sha
      type=ref,event=branch
      type=raw,value=latest,enable={{is_default_branch}}

Pushing the Docker Image to GitHub's Repository

With the tagging structure in place, and security scans running against the image, the next step is to login in to the image registry and push the newly created image. 

The following step handles authentication with the Docker container registry, which for this example is the GHCR:

- name: Log in to GHCR
      uses: docker/login-action@v3
      with:
        registry: ghcr.io
        username: ${{ github.action }}
        password: ${{ secrets.GITHUB_TOKEN }}

It’s important to note that the GITHUB_TOKEN is used instead of a personal access token (PAT). The GITHUB_TOKEN is a special credential that exists only in the context of a specific repository workflow. This limited operational context creates a much smaller attack surface compared to a PAT with access to multiple repositories.

The last step of the workflow uses the Docker build-push-action to build and push the image to the container registry:

- name: Build and push
       uses: docker/build-push-action@v5
       with:
       context: .
       push: true
       file: Dockerfile
       tags: ${{ steps.meta.outputs.tags }}
       cache-from: type=registry,ref=ghcr.io/${{ github.repository }}:buildcache
      cache-to: ${{ github.event_name == 'push' && 'type=registry,ref=ghcr.io/<organization>/image-build:buildcache, mode=max, image-manifest=true' || '' }}

There are some important options to call out:

  • push: true - This means that every time this workflow is triggered, an image will be pushed. Since each build has a unique tag, pushing each time is appropriate. 

  • tags: $ {{ steps.meta.outputs.tags }} - This takes all of the tags configured in the meta step, and applies them to the container image.

  • cache-from: This instructs the build action to use an image with the tag buildcache as a cache source. This can save on build times, which will cut down on billed GitHub Actions minutes.

  • cache-to: This will push a new image to the cache to be used in the cache-from: directive if the pull request is merged and pushed to the default branch. This way, the cache source is only updated when a new build is validated. Note that <organization> should be replaced with the GitHub organization; the conditional logic requires hard coding the name.

Now there’s a scanned, up-to-date base image, the next step is vendoring that image for developers to use in their workflows.

Deploying, Distributing and Managing Docker Images

For developers building on the Divio platform, this article provides a great example configuration for building and deploying a NodeJS application to Divio. To integrate the custom base image that we’ve now built, a couple of changes will be needed to the Divio deployment configuration.

Update the Dockerfile

The Dockerfile currently uses a public base image and needs to be updated to use the custom image; instead of:

FROM node:18-alpine

It should be changed to:

FROM ghcr.io/<organization>/image-build:latest 

Where <organization> is the name of the GitHub organization from the previous steps.

Create an Access Token

GitHub Actions currently requires an access token with the following scopes: repo, user, write:packages  to be able to pull images from a private GHCR registry. This token should be added to a GitHub Actions secret in the application repository with the name REPO_TOKEN:

Update the GitHub Actions Workflow

The GitHub Actions workflow needs to be updated to perform the following actions:

  • Ensure that the custom base image is being used

  • Login to the GitHub Container Registry

  • Pull the image prior to build.

Ensure the Custom Image is Used

The following step can be added to the GitHub Actions workflow to ensure that the custom image is being used. If it detects that it isn’t, the workflow will generate an error message and stop. This should be placed before the image is uploaded to Divio:

   - name: Check Dockerfile for base image
      run: |
        BASE_IMAGE="ghcr.io/<organization>/image-build:latest"         DOCKERFILE_CONTENT=$(cat Dockerfile)
        if [[ "$DOCKERFILE_CONTENT" != FROM\ $BASE_IMAGE ]]; then
          echo "Dockerfile must use $BASE_IMAGE as the base image."
          exit 1
        fi

Login to the GHCR

This step should be added to the workflow next. The configuration is similar to the login step for the base image, with the exception of the login credentials:

- name: Log in to GHCR
  uses: docker/login-action@v3
  with:
    registry: ghcr.io
    username: ${{ github.actor }}
    password: ${{ secrets.REPO_TOKEN }}

Pull the Base Image Prior to Build

In the deploy step, pulling the custom image needs to be added before Divio attempts to build and deploy the image:

- name: Deploy test app
    run: |
docker pull ghcr.io/<organization>/image-build:latest
    curl -X POST --data "environment=${{ secrets.TEST_ENVIRONMENT_UUID }}" --header “Authorization: Token ${{ secrets.API_TOKEN }}" https://api.divio.com/apps/v3/deployments/

Note that the exact implementation of this may vary depending on how Divio builds are triggered; if separate automation (such as a shell script) is used, the image pull will need to be added there.

Wrap-up

GitHub Actions and the GitHub Container Registry provide a full-featured solution for building and hosting Docker images, integrated with an organization’s existing source code repositories. With this workflow, developers can take advantage of a secure, standardized golden image. Combining this with the Divio platform delivers a secure, end-to-end application build and deployment pipeline.