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
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.
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.
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.
The workflow for building and pushing will be broken down into three main stages:
Build image with dependencies and any tooling needed.
Scan the image for any potential vulnerabilities and outdated packages.
Pushing the Docker image to the GitHub Container Repository.
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}}
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.
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.
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.
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
:
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.
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
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 }}
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.
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.