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); } };
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.