Post

GitHub Actions: Create a Docker Container Action Hosted in a Private Image Registry

Create a Docker container action that is hosted in a private image registry

Overview

Docker container actions can be awesome! They very neatly encapsulate an entire step’s logic into a container image so that no matter what host the Actions runner is running on1, you get the same result. There are a few popular actions in the marketplace that are Docker container actions, such as the SonarQube Scan action and the Super-Linter Action. You can easily tell that these are Docker container actions because their action.yml file has a using: 'docker' line in it. You can find other examples of Docker container actions by using GitHub’s search.

Sometimes, it makes a lot of sense to use Docker container actions, especially in the SonarQube and Super-Linter action examples above. These actions rely on source files / binaries to exist to run the scan or linting. However, there are a fair share of Docker container actions on the marketplace that didn’t really need to be Docker container actions. Part of this is likely due that JavaScript and Docker actions were the only type of action originally (Actions was launched in 2018). Composite actions came around originally in August 2020 and were limited to only using run: command steps.

However, sometimes I don’t think it makes sense to create a Docker action. I don’t necessarily like when I see simple actions calling a REST API endpoint run as a Docker action due to some of the limitations and theoretical overhead of running a Docker container.

With that caveat out of the way, let’s dive into creating Docker container actions, as well as how to use a private image registry to host the Docker image.

Container jobs are different than Docker actions! If you are using a container job, there are ways to natively provide authentication. See my post on container jobs for more information!

Docker Action Hosting Options

When creating a Docker action, you can either specify a local Dockerfile (that means this has to be built every time the Action runs) or you can specify a public Docker registry like Docker Hub or GitHub Container Registry. The SonarQube Scan action uses a local Dockerfile. Conversely, the SILE Typesetter action uses a public GitHub Container Registry image.

You’ll note the key word here: public. If you want to use a private image registry, you’ll need to authenticate to that registry. This is where things get a bit more complicated. If you pay attention to the workflow’s logs, you’ll notice that the Docker container actions are pulled or built as the first step of the job, in the initialization step. This runs before any of the steps in the job run. This is important to note because of course, you typically need to authenticate to a private image registry. But how can we provide authentication if we can’t otherwise run a step before the initialization step?

I have been asked this by a few customers as well as provided steps to implement this in a GitHub Discussion thread, so I wanted to capture this in a blog post so that others can benefit from this knowledge.

Connecting to a Private Image Registry

Somehow, we need to inject authentication before the job runs, or at least be able to dynamically provide credentials to the private image registry. There are a few ways to do this:

  1. If you are running this in a self-hosted environment, you could pre-bake the Docker credentials onto the self-hosted runner host. This is not ideal because this could be a security risk if the runner host is compromised and sort of defeats the purpose of completely portal and ephemeral runner environments.
  2. You could add a runner pre-job step. This would only work on self-hosted runners, and the authentication mechanism between your runner and the secret store would still provide a challenge.
  3. You could customize with the container commands by using the Runner Container Hooks. This also only works for self-hosted runners and has a moderate amount of setup required.
  4. Use a composite action to wrap/hide and reference the Docker action as a local action reference. The local action then references the real Docker container action that needs authentication to the private image registry. This would work with GitHub-hosted runners and self-hosted runners!

I’ll be going over the later two options in detail in the next sections.

Using the Container Customization Commands

The container customization commands are really powerful, and if we’re using self-hosted runner infrastructure, this can be a great way to inject credentials into the runner environment. Specifically, we can use the run_container_step hook that is called once for every container action in the job. This method uses Runner Container Hooks.

Here is an example of running container hooks. I am only adding logging to show how the hooks are called. You would need to add the logic to authenticate to your private image registry.

Using container hooks to customize container commands Using container hooks to customize container commands

To set this up, you need:

  1. Follow the instructions in the repository to set this up on your self-hosted runner
    • Clone the repository to your self-hosted runner
    • Run the npm install and npm run build commands in the designated folders in the repository
    • Set a .env file with the GITHUB_ACTIONS_RUNNER_HOOKS variable set to the absolute path of the ./packages/docker/lib/index.js file
  2. The next time you run a container Action, you should see similar logs to what I have above!

Credits to my co-worker, @tspascoal, for much of the help on this method!

Using a Composite Action and a Local Action Reference

We can use a composite action (or, several composite actions I should say) to reference a Docker container action behind a local action reference. I admit that this is a bit of a hack, but it works, and it works with both GitHub-hosted and self-hosted runners! The idea is that at workflow compile time, Actions doesn’t know about the Docker action since it is only being referenced locally from within a composite action. Therefore, it doesn’t authenticate to the image registry / pull the image until the composite action is rendered and we can authenticate to the private image registry ahead of time accordingly.

You can’t simply use a composite action to reference a Docker action normally, Actions is smart enough to render the Docker action as a Docker action and attempt to pull the image before the job starts. The action referenced by the composite action must be a local action reference. Also, the local action itself cannot be a Docker action, it still needs to reference the Docker action separately (AKA, in my tests, a minimum of 2 composite actions are needed here).

Let’s set this up! In my example, I am storing a Docker action in a private Azure Container Registry.

Note: My repository has 2 examples, so you’ll notice that I have a subfolder nested-composite-action-better. You don’t have to do it this way; nested-composite-action-better/action.yml could live in the root of the repo as ./action.yml The get-path/action.yml and the 3-action-implementation/action.yml files have to live in subfolders since we can only have one action.yml file in a directory. And technically, get-path/action.yml could live in a separate repo altogether.

Before diving into the YML, and with the note above in mind, let’s take a look at the file structure of the core components of this solution as I think it will make slightly easier to understand:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
joshjohanning-org/call-private-registry-docker-actions@main/ # "app" repo in this scenario
└── .github/
    └── workflows/
        └── my-awesome-workflow-testing-docker-private-action.yml

joshjohanning-org/composite-action-private-registry-docker-actions@main/ # orchestration repo
└── nested-composite-action-better/
    ├── 1-action/
    │   └── action.yml
    ├── 2-get-path/
    │   └── action.yml
    └── 3-action-implementation/
        └── action.yml

joshjohanning-org/simple-docker-action@main/ # Docker container action repo
└── private/
    └── action.yml

I’ve broken up the components into three separate composite actions and the one Docker container action:

  1. The main orchestration composite action (called by my-awesome-workflow-testing-docker-private-action.yml in the example above)
  2. The get-path action that retrieves the dynamic local file path (the actions branch/tag/ref is part of the file path) (this action could live in a separate repository)
  3. The local action that references the Docker container action in another repository
  4. In a separate repository (requirement), the Docker container action (joshjohanning-org/simple-docker-action/private@main in the example above)

For workflows referencing this solution, they would reference the 1-action/action.yml action as such:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
name: ci-7-nest-composite-action-in-remote-composite-action-better
run-name: nest composite action in remote composite action better

on:
  push:
    branches: [ "main" ]
  pull_request:
    branches: [ "main" ]
  workflow_dispatch:

jobs:
  build:
    runs-on: ubuntu-latest
    steps:
      - uses: actions/checkout@v4
      - name: simple-docker-action
        uses: joshjohanning-org/composite-action-private-registry-docker-actions/nested-composite-action-better/1-action@main
        with:
          password:  ${{ secrets.ACR_PASSWORD }}

Now, here are the rest of the workflow files as promised.

This is the main orchestration composite action, and the one that users would reference directly in their workflows:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
name: 'private registry docker action test'
description: 'private registry docker action test'
inputs:
  password:  
    required: true
    description: acr password
runs:
  using: "composite"
  steps:
    - name: back up docker creds
      run: cp ${{ runner.workspace }}/../../.docker/config.json ${{ runner.workspace }}/../../.docker/config.json.bak
      id: docker-backup
      shell: bash
    - name: auth to acr
      run: echo ${{ inputs.password }} | docker login --username test123j --password-stdin test123j.azurecr.io
      shell: bash

    # get and output path to composite action
    - uses: joshjohanning-org/composite-action-private-registry-docker-actions/nested-composite-action-better/2-get-path@main
      id: composite-path

    # copy composite action to tmp folder - the action-implementation folder is where you define what docker action to reference
    - run: |
        mkdir -p __tmp
        cp -r '${{ steps.composite-path.outputs.path }}/3-action-implementation' __tmp/action-implementation
      shell: bash

    # run the local composite action w/ docker private registry
    - uses: ./__tmp/action-implementation

    - run: rm -rf __tmp/action-implementation
      name: cleanup action
      shell: bash
      if: always()

    - run: mv ${{ runner.workspace }}/../../.docker/config.json.bak ${{ runner.workspace }}/../../.docker/config.json
      name: restore original docker creds
      shell: bash
      if: always() && steps.docker-backup.conclusion == 'success'

    - run: cat ${{ runner.workspace }}/../../.docker/config.json
      name: print docker registries
      shell: bash
      if: always() && steps.docker-backup.conclusion == 'success'

This is an action that gets the dynamic file path to the local composite action (this action doesn’t technically have to exist in the same repository, unlike the others, but it could). Since actions are cloned during the job initialization step, we can take advantage of this so that we don’t have to provide any additional Git authentication to retrieve these files. Tiago talks about this trick more in-depth here!

1
2
3
4
5
6
7
8
9
10
11
12
name: 'private registry docker action test - get path'
description: 'private registry docker action test - get path'
outputs:
  path:
    description: action path
    value: ${{ steps.get-path.outputs.path }}
runs:
  using: "composite"
  steps:
    - run: echo 'path=${{ github.action_path }}/..' >> $GITHUB_OUTPUT
      id: get-path
      shell: bash

This is the 🪄 - the local action that then references an external Docker container action that is using a private image registry:

1
2
3
4
5
6
name: 'private registry docker action test - call the private docker action'
description: 'private registry docker action test - call the private docker action'
runs:
  using: "composite"
  steps:
    - uses: joshjohanning-org/simple-docker-action/private@main

And finally, this is the Docker action that is referencing a private container registry:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
name: 'Hello World'
description: 'Greet someone and record the time'
inputs:
  who-to-greet:
    description: 'Who to greet'
    required: true
    default: 'World'
outputs:
  time:
    description: 'The time we greeted you'
runs:
  using: 'docker'
  image: 'docker://test123j.azurecr.io/actions/simple-docker-action:1'
  args:
    - ${{ inputs.who-to-greet }}

For the workflow (e.g.: app repo) calling this, this is all that they would see:

Using composite actions to call a docker container action hosted in a private image registry Using composite actions to call a docker container action hosted in a private image registry

😮‍💨 That’s a lot of steps, I know! Ideally you can centrally store the 2-get-path/action.yml. You could also enhance this so that the 3-action-implementation/action.yml has the downstream Docker container action repository/ref dynamically injected so you don’t have to create this same exact structure for every Docker action you want to reference a private image registry. You could use bash to modify the referenced action in 3-action-implementation/action.yml before it’s called, thus only requiring one copy of the main orchestration bits. But the concept is at least proven out with the above example!

Thanks to my co-worker, @tspascoal, for a ton of help on this method as well!

I do have another example of this here that only uses two composite actions as opposed to three composite actions like I use in the example above. It would work just as well, it just isn’t as “fancy” as the example above in setting an output parameter and ensuring proper handling of the Docker credentials. Feel free to borrow upon this idea as well!

Summary

We sometimes forget that GitHub Actions serves both the opensource and enterprise community. Some consider it a missing feature or limitation that you can’t authenticate to a private image registry for Docker container actions, but I can understand how the experience using marketplace actions would be hindered if some actions had a requirement to authenticate to a private image registry before running. However, with a little creativity, we can still use Docker container actions with a private image registry without compromising too much on the user experience and security of your workflows.

If you were only using self-hosted runners with no possibility of ever using GitHub-hosted runners, customizing the container commands is probably the best way to go. But if you want to use GitHub-hosted runners and be able to take advantage of the benefits of not having to maintain your own runner infrastructure, the composite action method is your best bet.

And remember, sometimes Docker container actions are the exact right tool for the job, but sometimes they introduce unnecessary complexity and overhead using containers for container’s sake to run simple commands.

Let me know if you have any feedback or suggestions in the comments ⬇️. Happy Actioning!! 🚀 🚢

Footnotes

  1. Docker container Actions only work on Linux runners, not Windows or macOS runners. Docker container Actions also don’t work on Actions-Runner-Controller using Kubernetes mode (only Docker-in-Docker mode). ↩︎

This post is licensed under CC BY 4.0 by the author.