Featured image
07 Dec 2024 8 min read Development

Docker Argument vs Variable vs Secret

You might be wondering how to pass a value while building a Docker image. I’ve been there; in fact, I still revisit this topic from time to time. So I want to dedicate a post to building an image with arguments, variables, and secrets.

Let’s start with a simple Dockerfile with multiple stages:

FROM golang:1.23
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("hello, world")
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]

The FROM statements you saw in the example above define stages. The first one builds the file, and the second runs the built binary.

Now let’s talk about build arguments and environment variables. Build arguments in Docker are closely related to environment variables; they are written as ARG and ENV, respectively. They are used to pass information to the build process.

Build Arguments

The way you use ARG is by defining it either before the stage (a.k.a Global Scope) or inside the stage (a.k.a Local Scope).

The benefit of using ARG is we can build the image in different way without editing the Dockerfile.

The value of ARG is only available during the build process and will not be available when the image is running as a container, unless it is explicitly passed.

I recommend using ARG for configuring builds. For example, to build a different version, or to use different base image.

This is an example of using ARG to define the base image to be used for the build.

# create argument for image version
ARG GO_VERSION="1.23"

# use argmument
FROM golang:${GO_VERSION}

# rest of the file stay the same as first example
...

Try to build the example above, and you will see that it uses golang:1.23

docker build -t example:latest .

But if you pass a different version, let’s say 1.22, it will use golang:1.22 instead.

docker build --build-arg GO_VERSION="1.22" -t example:latest .

Without Default Value

We can also just set the argument name without defining the value. This way, the value of the argument will be empty if not provided in the build command.

# create argument without default value
ARG GO_VERSION

# use argmument
FROM golang:${GO_VERSION}

# rest of the file stay the same as first example
...

Scope

If we declare ARG in the global scope and want to use it inside a stage, ARG has to be redeclared inside the stage to allow the stage to read the value. There’s no need to write the value again; just the name is enough for the stage to inherit the value.

This version will not work

ARG MESSAGE="helo, world"

FROM golang:1.23
WORKDIR /src
# this will fail
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("${MESSAGE}")
}
EOF
RUN go build -o /bin/hello ./main.go

# rest of the file stay the same as first example
...

With redeclaration, it now works perfectly.

ARG MESSAGE="helo, world"

FROM golang:1.23
# inherit the arg from global scope
ARG MESSAGE 
WORKDIR /src
COPY <<EOF ./main.go
package main

import "fmt"

func main() {
  fmt.Println("${MESSAGE}")
}
EOF
RUN go build -o /bin/hello ./main.go

# rest of the file stay the same as first example
...

Environment Variables

The way you use ENV is by defining it inside the stage (a.k.a Local Scope).

The benefit of using ENV is we can configure the way our container run.

The value of ENV available during build process and when the image is running as container.

I recommend using ENV for configuring runtime. For example, to set API endpoints. I don’t recommend using it for sensitive values like passwords.

To pass environment variables during build, we need to use build arguments. This is why I previously said they are closely related.

ARG USER

FROM golang:1.23
WORKDIR /src
COPY <<EOF ./main.go
package main

import (
  "fmt"
  "os"
)

func main() {
  fmt.Println("Hello", os.Getenv("USER_NAME"))
}
EOF
RUN go build -o /bin/hello ./main.go

FROM scratch
# inherit & copy the arg from global scope
ARG USER 
ENV USER_NAME=${USER}
COPY --from=0 /bin/hello /bin/hello
CMD ["/bin/hello"]

Now, after building it, the environment variables will be available in the container, and they can be accessed programmatically (os.Getenv("ENV_NAME") in Go or process.env.ENV_NAME in Node.js).

docker build --build-arg USER="example" -t example:latest .

If you are wondering why the USER_NAME is called by the code in the first stage (build), but the environment variable is copied to the second stage (run), I have an answer for you.

This is because the first stage does not need the environment variables to be present; it will still compile. Once compiled, the binary runs in the second stage, which is where we want the environment variables to be present. Hence, we copied them to the second stage and not the first stage.

Build Argument vs Environment Variables

So here’s a TL;DR version of Build Arguments vs Environment Variables.

Aspect Build Arguments (ARG) Environment Variables (ENV)
Declaration Using ARG keyword Using ENV keyword
Scope Can be Global or Local (stage) Local (stage) only
Availability Only during build time (unless explicitly passed) During build time and container runtime
Best Used For Build configurations (versions, base images) Runtime configurations (API endpoints)

Build Secrets

Why would there be build secrets when build arguments and environment variables already exist?

Well, they are for storing sensitive information like passwords or API keys. By using build secrets, the sensitive information will not be exposed.

Let’s take an example of passing an API key when building a frontend app in JavaScript:

ARG SECRET_API_KEY

FROM node:20 as build

WORKDIR /app
COPY package*.json ./

RUN npm install
COPY . .
ARG SECRET_API_KEY
RUN SECRET_API_KEY=${SECRET_API_KEY} \
    npm run build

FROM node:20
RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]

And build it by passing the secret using an argument:

docker build --build-arg SECRET_API_KEY="example" -t example:latest .

Yeah, it did work with build arguments, and even if we pass that argument to an environment variable before using it in the build, it will still work.

The problem is we are leaking sensitive information. Docker will also print a warning message about this.

Let’s change the implementation to use build secrets.

FROM node:20-alpine AS build

WORKDIR /app
COPY package*.json ./

RUN npm install
COPY . .
RUN --mount=type=secret,id=SECRET_API_KEY \
    SECRET_API_KEY=$(cat /run/secrets/SECRET_API_KEY) \
    npm run build

FROM node:20-alpine
RUN npm install -g serve
COPY --from=build /app/dist ./dist
EXPOSE 3000
CMD ["serve", "-s", "dist", "-l", "3000"]

We will also change the build command and add the environment variable to the shell before executing the build command:

export SECRET_API_KEY=example
docker build --secret id=SECRET_API_KEY -t example:latest .

No more warning messages, and we successfully pass the secret to the build. Yay!

Environment Variables vs Build Secrets

So here’s a TL;DR version of Environment Variables vs Build Secrets.

Aspect Environment Variables (ENV) Build Secrets
Declaration Using ENV keyword Using --mount=type=secret
Visibility Visible in image history and container Not visible in image history
Security Level Lower (plaintext) Higher (secure during build)
Best Used For Non-sensitive configuration Sensitive data (API keys, credentials)

Photo by Ian Taylor on Unsplash