How to secure your Docker images with Docker BuildKit and GitHub Actions
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.