Understanding environment variables in multi-stage Docker builds

I was recently working with a multi-stage Dockerfile for a Node.js application. The Dockerfile consisted of multiple stages: a base stage for setting up the environment, a builder stage for compiling the application, and a runner stage for executing the application. I found out the hard way that the stage where arguments and environment variables are defined matters in such multi-stage Dockerfiles.

Understanding Environment Variables and Build Arguments

In Docker, ARG and ENV directives are used to define build arguments and environment variables, respectively. However, these values do not persist across stages. Docker simply discards them at the end of each stage. In my Dockerfile, the PORT argument was defined in the builder stage but not in the runner stage.

Here's an excerpt from my Dockerfile:

# syntax=docker/dockerfile:1
ARG NODE_VERSION=23.4.0

FROM node:${NODE_VERSION}-alpine AS base
WORKDIR /app

# ... (rest of the base stage)

FROM base AS builder
# ... (builder stage)
# This ARG value may come for example from docker-compose.yml or .env. As shown, it defaults to 3000 if not set
ARG PORT=3000
ENV PORT=${PORT}

# ... (rest of the builder stage)

FROM base AS runner
WORKDIR /app
COPY --from=builder /app/build ./build
EXPOSE ${PORT}

And this is how I use the PORT environment variable in my application code:

const start = async () => {
  const port = Number(process.env.PORT);
  try {
    await server.listen({ port, host: "0.0.0.0" });
    console.log(`Server is running on port ${port}`);
  } catch (err) {
    server.log.error(err);
  }
};
Databases in VS Code? Get DevDb

The issue was that the PORT environment variable was not available in the runner stage. This is because the ARG value was defined in the builder stage but not in the runner stage. Hence, the PORT environment variable was not available in the runner stage, and kept defaulting to the default value of 3000 as specified in the Dockerfile.

The Right Way to Define Environment Variables Across Stages

Since ARG and ENV directives are scoped to individual build stages, to make a variable available in multiple stages, it needs to be defined before the stages where it's needed

Corrected Dockerfile Example

# syntax=docker/dockerfile:1

# Global ARG available in all stages
ARG PORT=3000
ARG NODE_VERSION=23.4.0

FROM node:${NODE_VERSION}-alpine AS base
# Redeclare ARG in each stage where needed
ARG PORT
WORKDIR /app

ENV PORT=${PORT}

# ... (rest of base stage configuration)

FROM base AS builder
ARG PORT
ENV PORT=${PORT}

# ... (builder stage configuration)

FROM base AS runner
ARG PORT
ENV PORT=${PORT}
WORKDIR /app
COPY --from=builder /app/build ./build
EXPOSE ${PORT}

Conclusion

Understanding how Docker handles environment variables and build arguments across different stages is crucial in multi-stage Dockerfiles. By declaring ARG and ENV directives in the correct stages and using default values in application code, we can ensure that our containers behave as expected. I now simply prefer to declare global build arguments and environment variables at the top of the Dockerfile to avoid issues with persistence across stages, instead of having to re-declare them in each stage. You can also consider using .env files to manage these values consistently across development, testing, and production environments.

Wanna chat about what you just read, or anything at all? Click here to tweet at me on 𝕏