How to secure your Docker images with Docker BuildKit and GitHub Actions

I love building great software products that help people. I have 10+ years of experience in software engineering and 5+ years building on cloud-native platforms (AWS). Before working for businesses, I pursued my PhD in theoretical physics, where I found my passion for building software products.
The issue with leaking secrets
It has been known for a long time that docker images can be prone to leaking secrets. That means in case an attacker gets access to your Docker images, they can find your secrets by inspecting the docker history to look for build arguments or look through intermediate layers to find any secret files that might be present in previous layers.
So, if you can't use ENV, ARG or COPY, how do you get the secret into your image to e.g. access a private repository via .npmrc? (I'll be using the example of accessing a private NPM repository with an access token NPM_TOKEN which is put in a .npmrc throughout this article but the solution applies to any secret.) The recommended solutions that avoid this problem are multi-stage builds and Docker Buildkit. The multi-stage build separates the Dockerfile into a builder and a runner stage where the runner stage only gets the minimal data needed to run in production and doesn't inherit any intermediate layers from the build process. You can find an example here. While this is a good solution, there are some downsides to it: the Dockerfile is more complex and the build can take a bit longer. Additionally, SonarCloud has started to look for the usage of ENV or ARG to handle secrets. It's not smart enough to identify a multi-stage build, so I explored the other option: Docker Buildkit.
Using Docker Buildkit on Desktop
The mount file option
On my machine, it was very easy to set it up as BuildKit is already enabled by default on Docker Desktop. Assuming you have the file with the secret present on the host machine like ./.npmrc, you pass it to the docker buildx build command instead of docker build with the --secret option and give it a name via the id notation:
> docker buildx build --secret id=npm,src=./.npmrc .
Then you can use it inside the Dockerfile with the --mount option by referring to the id=npm secret:
RUN --mount=type=secret,id=npm,target=./.npmrc \
npm ci
Using the environment option
While it is documented in the Docker CLI reference documentation, it wasn't immediately obvious to me that you can use the --secret option also for environment variables and not just for files. For this, you can use --secret id=NPM_TOKEN when running docker build with BuildKit, where the id should refer to an actual environment variable that contains a secret. You can then use it with
RUN --mount=type=secret,id=NPM_TOKEN \
NPM_TOKEN=$(cat /run/secrets/NPM_TOKEN) \
npm ci
Note that the --mount command only puts the content of NPM_TOKEN in a file under /run/secrets. To use it as an environment file again, you must load it with cat. This way is a bit cleaner as you don't have to generate a .npmrc with the token inside but instead can keep it clean and checked into git with just
//registry.npmjs.org/:_authToken=${NPM_TOKEN}
and whatever other settings you want to have in there.
Making it work with GitHub Actions
As hinted at before, the setup is only that easy on Docker. Depending on your CI/CD provider, you may have to change the docker settings to enable it. With GitHub Actions (GHA), it's luckily quite easy as there is an action from Docker:
- name: Set up Docker Buildx
uses: docker/setup-buildx-action@v2
However, when you add that step in front of your docker build and docker push step, you will stumble over the fact that docker push cannot find the docker image you build previously. The reason is that for docker buildx we have to specify the export action for the build result with --output. So our build command becomes
> docker buildx build \
--output='type=docker' \
--secret 'id=NPM_TOKEN' \
-t $(IMAGE):$(TAG) .
> docker push $(IMAGE):$(TAG)
And behold: We have successfully pushed an image using BuildKits secure mounts with our CI/CD pipeline 🎉.
Bonus: Docker BuildKit Secret Mount in Docker Compose
You might also be using docker-compose to simplify your local developer experience. The approach above is compatible with Docker Compose. Since last summer, it supports files, externally created docker secrets or environment variables but many other resources don't mention the environment variable option yet. First, you need to add a top-level secrets object to your docker-compose.yml containing a key describing the secret you want to use. In our case, we will add the NPM_TOKEN secret, which uses the NPM_TOKEN environment variable. Then, we use it in the description of our service my-service in the build.secrets array (make sure you add the secrets under build and not under my-service. The latter will result in a run-time secret that is injected when the container is run but we need a build-time secret that is injected when we build the image). So overall, we have something like this
version: "3.8"
services:
my-service:
build:
context: .
target: builder
secrets:
- NPM_TOKEN
secrets:
NPM_TOKEN:
environment: NPM_TOKEN
And there you have it. Now you can benefit from Docker BuildKit in most common scenarios and never worry about leaking your secrets with Docker images again.