How to secure your Docker images with Docker BuildKit and GitHub Actions
4 min read
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
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
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
> docker buildx build --secret id=npm,src=./.npmrc .
Then you can use it inside the
Dockerfile with the
--mount option by referring to the
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
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
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.
Did you find this article valuable?
Support Bijan Chokoufe Nejad by becoming a sponsor. Any amount is appreciated!