Publishing Docker Images with GitHub Actions

Christian Emmer
Christian Emmer
Feb 20, 2021 · 10 min read
Publishing Docker Images with GitHub Actions

Publishing Docker images is a common CI/CD task, and the tight integration GitHub Actions has with GitHub repositories makes it a great tool for the job.

Motivation

Automated builds, when combined with tests, are a great tool to increase iteration speed on projects through time savings.

Imagine you maintain a public Docker image of an application that has frequent version changes - every time a new version is released you'll have to spend time building, tagging, testing, and publishing a new version. Now imagine you had a tool to update the application version for you automatically in your Dockerfile, and a CI/CD workflow that would take care of the building, tagging, testing, and publishing of the image - it could maintain itself indefinitely without your intervention.

We'll touch on the second part here, the CI/CD workflow for Docker images.

About GitHub Actions

GitHub Actions left beta about a year ago, and has been growing in popularity as a replacement for other CI/CD services in part to its tight integration with GitHub repositories.

As of writing, the Actions free plan offers unlimited build minutes for public repositories, 2,000 build minutes per month for private repositories, and 20 concurrent jobs - much more than other freemium services.

Setup

For this project you will need a couple things:

Building the image

Let's make a simple hello world Dockerfile:

FROM alpine:3.13.2
CMD ["echo", "Hello world!"]

This can be tested with a command such as:

$ docker build --tag helloworld . && docker run helloworld
Hello world!

Then we'll create the beginnings of our Actions workflow at .github/workflows/main.yml:

name: CI/CD

# Run on pushes to any branch
on: push

jobs:
  build:
    name: Docker build

    # Use the Ubuntu virtual environment to run this job in
    runs-on: ubuntu-latest

    steps:
      # Checkout the repository files
      - name: Checkout
        uses: actions/checkout@v2

      # Build the hello world image
      - name: Build Docker image
        run: docker build --tag helloworld .

With this config, GitHub Actions will execute the workflow on every code push, so once these files are committed and pushed, Actions will run docker build and continue to on every subsequent push. The workflow should finish in under 15 seconds, and if we see that succeed in the Actions or Pull Request UI then we know the image built successfully.

Publishing the image

Building the Docker image is great, but that image artifact disappeared when the job finished. In order to save our work we'll want to publish the image - what this post is all about!

First, we'll want to set an image name to publish under. To keep things easier to read, I will collapse some parts of the Dockerfile that haven't changed.

name: CI/CD

on: push

# Define the image tag
env:
  IMAGE_TAG: <username>/helloworld:latest

jobs:
  build:
    name: Docker build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Build Docker image
        # Tag using the image tag above
        run: docker build --tag "${IMAGE_TAG}" .

Replace <username> with your own Docker Hub username.

To save the output of the build to be used later, we'll save it to the workflow's "artifacts":

name: CI/CD

on: push

env:
  IMAGE_TAG: <username>/helloworld:latest

jobs:
  build:
    name: Docker build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Build Docker image
        run: docker build --tag "${IMAGE_TAG}" .

      # Archive and persist the Docker image
      - name: Save Docker image
        run: docker save --output image.tar "${IMAGE_TAG}"
      - name: Upload Docker image
        uses: actions/upload-artifact@v2
        with:
          name: docker-image
          path: image.tar
          retention-days: 1

Because the image is very small, even archiving and saving the output should still keep our build time under 15 seconds.

One thing to note is the action actions/upload-artifact@v2 is very slow, I've seen it take more than 8 minutes for an almost 3GB Docker image. That's an extreme case, no sane image should be that large, but it's something to keep in mind.

In order to push images to Docker Hub we'll need to supply GitHub with our credentials. In your GitHub repository's settings there is a page to configure secrets. We'll need a secret named DOCKERHUB_USERNAME with the value of your username, and one named DOCKERHUB_PASS with the value of a personal access token created for GitHub. I would recommend against saving your password in plaintext.

Now we're ready to add a job to log in to Docker Hub and push the image:

name: CI/CD

on: push

env:
  IMAGE_TAG: <username>/helloworld:latest

jobs:
  build:
    name: Docker build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Build Docker image
        run: docker build --tag "${IMAGE_TAG}" .

      - name: Save Docker image
        run: docker save --output image.tar "${IMAGE_TAG}"
      - name: Upload Docker image
        uses: actions/upload-artifact@v2
        with:
          name: docker-image
          path: image.tar
          retention-days: 1

  push:
    name: Docker push
    runs-on: ubuntu-latest

    # Build needs to finish first
    needs: build

    # Only push images from the main branch
    if: github.ref == 'refs/heads/main'

    steps:
      # Load and un-archive the Docker image
      - name: Download Docker image
        uses: actions/download-artifact@v2
        with:
          name: docker-image
      - name: Load Docker image
        run: docker load --input image.tar

      # Log in to Docker Hub and push the image
      - name: Publish Docker image
        env:
          DOCKERHUB_USERNAME: ${{ secrets.DOCKERHUB_USERNAME }}
          DOCKERHUB_PASS: ${{ secrets.DOCKERHUB_PASS }}
        run: |
          echo "${DOCKERHUB_PASS}" | docker login --username "${DOCKERHUB_USERNAME}" --password-stdin
          docker push "${IMAGE_TAG}"

Upon pushing that change, GitHub will publish a public image named helloworld with a single tag latest under your Docker Hub account. The whole workflow should take less than 30 seconds.

Bonus: testing the image

I've previously talked about testing Docker images with Container Structure Test, a great tool from Google to test your built docker images - and it's very easy to add to our GitHub Actions workflow.

Testing Docker Images with Container Structure Test

Jul 18, 2020 · 5 min read

Just because a Docker image builds successfully doesn't mean it will perform as expected. Google's container Structure Test tool helps you check images to make sure they're working as intended.

Testing Docker Images with Container Structure Test

First, make a container-structure-test.yml:

schemaVersion: 2.0.0
commandTests:
  - name: "docker run"
    # `command` is required, so we'll have it match the Dockerfile
    command: "echo"
    args: ["Hello world!"]
    expectedOutput: ["Hello world!"]
    excludedError: [".+"]
    exitCode: 0

This isn't a very valuable test because it replaces all the functionality of the Dockerfile, allowing for the two get out of sync - but it will work well enough to show the Actions config.

To run Container Structure Test in our Actions workflow, add a step just after docker build:

name: CI/CD

on: push

env:
  IMAGE_TAG: <username>/helloworld:latest

jobs:
  build:
    name: Docker build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      - name: Build Docker image
        run: docker build --tag "${IMAGE_TAG}" .

      # Run Container Structure Test against the built image
      - name: Test Docker image
        run: |
          curl -LO https://storage.googleapis.com/container-structure-test/latest/container-structure-test-linux-amd64 && chmod +x container-structure-test-linux-amd64 && sudo mv container-structure-test-linux-amd64 /usr/local/bin/container-structure-test
          container-structure-test test --config container-structure-test.yml --image "${IMAGE_TAG}"

      - name: Save Docker image
        run: docker save --output image.tar "${IMAGE_TAG}"
      - name: Upload Docker image
        uses: actions/upload-artifact@v2
        with:
          name: docker-image
          path: image.tar
          retention-days: 1

  # push: (collapsed)

If Container Structure Test doesn't pass, it will exit with a non-zero exit code, which will fail the step and the entire job.

Bonus: linting the Dockerfile

I've also previously talked about linting Dockerfiles with Hadolint, to both help enforce style guidelines and help catch potential issues. It's also a simple addition to our workflow.

Linting Dockerfiles with Hadolint

Aug 10, 2020 · 3 min read

Linters don't just enforce style guidelines, they also catch potential issues. hadolint (Haskell Dockerfile Linter) is the most popular linter for Dockerfiles, and it's incredibly easy to use.

Linting Dockerfiles with Hadolint

To run Hadolint, add a step just before docker build:

name: CI/CD

on: push

env:
  IMAGE_TAG: <username>/helloworld:latest

jobs:
  build:
    name: Docker build
    runs-on: ubuntu-latest
    steps:
      - name: Checkout
        uses: actions/checkout@v2

      # Lint the Dockerfile
      - name: Lint Dockerfile
        run: docker run --rm --interactive hadolint/hadolint < Dockerfile

      - name: Build Docker image
        run: docker build --tag "${IMAGE_TAG}" .

      - name: Save Docker image
        run: docker save --output image.tar "${IMAGE_TAG}"
      - name: Upload Docker image
        uses: actions/upload-artifact@v2
        with:
          name: docker-image
          path: image.tar
          retention-days: 1

  # push: (collapsed)

If Hadolint doesn't pass, it will exit with a non-zero exit code, which will fail the step and the entire job.

Other CI/CD tools

If GitHub Actions isn't the right tool for you, check out how to accomplish this same workflow using CircleCI.

Publishing Docker Images with CircleCI

Feb 15, 2021 · 10 min read

Publishing Docker images is a common CI/CD task, and the flexibility CircleCI offers makes it a great tool for the job.

Publishing Docker Images with CircleCI