In this article I would like to show you a serious "gotcha" of Dockerfile ARG and ENV instructions as well as how you can securely pass a secret only at build time to the process building your Docker image and why is that important.

While the example in this article is specific to PHP (namely, COMPOSER_AUTH secret for the composer install command), the same approach applies for any secrets you may require during a Docker image build time only.

You can enforce Dockerfile and building best practices by using BuiltKit's built-in pre-defined checks [1]. It's as simple as running the following command in the directory containing your Dockerfile:

docker build --check .

If by any chance this throws the following warning at you:

WARNING: SecretsUsedInArgOrEnv - https://docs.docker.com/go/dockerfile/rule/secrets-used-in-arg-or-env/
Do not use ARG or ENV instructions for sensitive data 

it means there is a high chance the Docker images you are currently using in production have some of your secrets embedded in them, even if those secrets were only required at build time. One example of such secret is COMPOSER_AUTH, which can be used to carry credentials for accessing private package repositories during composer install step.

Per the description of the SecretsUsedInArgOrEnv build check:

While it is common to pass secrets to running processes through environment variables during local development, setting secrets in a Dockerfile using ENV or ARG is insecure because they persist in the final image. This rule reports violations where ENV and ARG keys indicate that they contain sensitive data.

At this point, you may be considering mounting a file containing your secret. That's possible and already better. However, it does require you to write a sensitive secret to a plaintext file on your disk, which is not ideal. We're left with using an exported environment variable containing our sensitive secret - that sounds much better! Let's have a look how we can work with that.

Building the image - CLI

Let's assume you have your COMPOSER_AUTH env var exported in your current Bash session.

If you're accessing private repositories, it could have the following form:

{"github-oauth": {"github.com": "YOUR_GITHUB_TOKEN"}}

If you're accessing a third-party package, it could look like so:

{"http-basic": {"composer.fluxui.dev": {"username": "EMAIL_HERE","password": "LICENSE_KEY_HERE"}}

It could also have both of those secrets combined in one JSON.

The following example mounts the environment variable COMPOSER_AUTH to secret ID composer-auth, as a file in the build container at /run/secrets/composer-auth.

DOCKER_BUILDKIT=1 docker build --secret id=composer-auth,env=COMPOSER_AUTH .

The DOCKER_BUILDKIT env var is required for secret usage with Docker. Feel free to export it, so you don't have to pass it like this with every command. [2]

You could omit the env parameter and pass the name of the environment variable directly to id like so:

DOCKER_BUILDKIT=1 docker build --secret id=COMPOSER_AUTH .

However, it would mount the secret at /run/secrets/COMPOSER_AUTH and I prefer a little more control over the mount path as you'll see in the following sections.

Now that we've mounted the secret, here's how we can use it in a Dockerfile:

RUN --mount=type=secret,id=composer-auth \
    COMPOSER_AUTH=$(cat /run/secrets/composer-auth) \
    composer install

I omitted multiple composer install switches to focus on the important part here, which is the secret.

Our mount type is secret and we are mounting the secret with id of composer-auth (matching how we invoked docker build in CLI).

We then immediately proceed to initialize an env var with the value sourced from the mounted secret just before calling composer install.

That's it! Composer will now be able to use the value of COMPOSER_AUTH environment variable to access relevant private repositories.

Building the image - Docker Compose

Now that we know how to do this in CLI, let's see how that's done with Docker Compose. I'll keep this example focused with most of irrelevant parts redacted.

services:
  app:
    build:
      context: ./services/app
      ...
      secrets:
        - composer-auth # Available during build
    secrets:
      - composer-auth # Available during runtime

secrets:
  composer-auth:
    environment: COMPOSER_AUTH

First, we need to declare a top-level secrets section keyed by the secret id (in this case, it's composer-auth), which then maps the value from the indicated environment variable.

If you only require the secret at build time, you add a secrets key to your services build section.

If you would also like to have the secret available during run time when working with Docker Compose locally, add the secrets key additionally one level higher - on the same nesting level as the build section. This way, you'll have the secret mounted at /run/secrets/composer-auth during container run time.

Building the image - GitHub Actions

Moving on, let's see how we can get this working in GitHub Actions.

Let's start by heading to:

repository Settings > Security > Secrets and variables > Actions

and creating a COMPOSER_AUTH secret with the expected value.

With that done, here's how part of our GitHub Actions workflow could look like.

 - name: 🔨 Docker build with cache
        id: docker-build
        run: |
          docker build --secret id=composer-auth,env=COMPOSER_AUTH \
            -f .docker/Dockerfile \
            --target ci \
            -t app-ci
            .
        env:
          DOCKER_BUILDKIT: 1
          COMPOSER_AUTH: ${{ secrets.COMPOSER_AUTH }}

Again, I have skipped less relevant parts to keep focus on the secret usage.

As you can see, it is very similar to how we've run it in CLI. We first enable BuiltKit with DOCKER_BUILDKIT: 1, then initialize COMPOSER_AUTH env var with the value of the GitHub repository secret. Note the double curly braces syntax, which tells GitHub we're expecting a secret value here.

Finally, we pass the secret to the docker build command as we've done in CLI.

Building the image - GCP Cloud Build

Let's take it one step further and talk HCL (HashiCorp Terraform).  Here's how a sample trigger could look like. For clarity, I am keeping it short and focused, so feel free to flesh it out to your needs.

resource "google_cloudbuild_trigger" "build" {
  name     = "build"
  location = "global"

  build {
    source { ... }

    # Here you could also pull your latest image to use as cache for the build ahead,
    # but that's a topic for a whole separate article :)

    step {
      id   = "build"
      name = "gcr.io/cloud-builders/docker"
      entrypoint = "bash"
      env  = ["DOCKER_BUILDKIT=1"]
      secret_env = ["COMPOSER_AUTH"]
      args = [
        "-c",
        join(" ", [
          "docker",
          "build",
          "--build-arg",
          "BUILDKIT_INLINE_CACHE=1",
          "--secret",
          "id=composer-auth,env=COMPOSER_AUTH",
          "--file",
          ".docker/Dockerfile",
          "--target",
          "production",
          "--tag",
          "...",
        ])
      ]
    }

    available_secrets {
      secret_manager {
        env          = "COMPOSER_AUTH"
        version_name = "${composer_auth_secret.secret_name}/versions/latest"
      }
    }
  }
}

Note how none of the following instructions mention a KMS keyring or a KMS keyring resource, which you may see pop up in places when you research this subject online or with ChatGPT.

Let's start at the bottom. The available_secrets section indicates we expect the provided secret version name to be made available in the COMPOSER_AUTH env var.

Next is your build step. You should:

  • Map the environment variable to it's encrypted value using secret_env ; note that secret environment variables must be unique across all of a build's secrets, and must be used by at least one build step.
  • Enable DOCKER_BUILDKIT with an env var
  • Use a bash entrypoint with a docker build command you're by now already familiar with.

Time to rotate

Now that you know how to safely pass COMPOSER_AUTH to your Docker image build process, it is time to rotate that GitHub token of yours, so you can sleep well at night knowing no sensitive credentials are embedded in your Docker images.

And remember - never use Dockerfile's ARG+ENV to pass a secret or it'll be baked into the image otherwise.

[1] https://docs.docker.com/reference/build-checks/

[2] https://docs.docker.com/build/buildkit/#getting-started