Runtime environment variables in Next.js - build reusable Docker images
Source: Dev.to
Classification of environment variables by dimension
At first glance, environment variables may seem like just a few values needed when the app starts, but they are far more complex. Understanding the nature of each variable is essential for running the app and managing its configuration across multiple environments.
Dimensions
- When: build‑time, start‑time, run‑time
- Where: server (static, SSR (request), ISR), client
- Visibility: public, private
- Requirement: optional, required
- Scope: common for all environments (constant, config), unique
- Mutability: constant, mutable
- Git tracking: versioned, ignored
There are more dimensions, but these illustrate why managing environment variables can be challenging. This article focuses on the top three items (When, Where, Visibility) in the context of Next.js and Docker.
Next.js environment variables
The Next.js documentation includes a guide on environment variables covering:
.env*filenames loaded by default- Load order and priority
- Variable expansion
- Exposing and inlining variables with the
NEXT_PUBLIC_prefix for the client
The self‑hosting guide also explains how to opt into dynamic rendering so that variable values are read on each server‑component render, not just once at build time—useful for reusable Docker images.
The problem with build‑time environment variables
A common pattern after reading the docs is to scatter NEXT_PUBLIC_ and server variables throughout the codebase. When using Docker and GitHub Actions, this often leads to a setup like the following.
Dockerfile (excerpt)
# frontend/Dockerfile
# Next.js app installer stage
FROM base AS installer
RUN apk update && apk add --no-cache libc6-compat
# Enable pnpm
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@10.12.4 --activate
WORKDIR /app
# Copy monorepo package.json and lock files
COPY --from=builder /app/out/json/ .
# Install the dependencies
RUN pnpm install --frozen-lockfile
# Copy pruned source
COPY --from=builder /app/out/full/ .
# THIS: set build time env vars
ARG ARG_NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$ARG_NEXT_PUBLIC_SITE_URL
RUN echo "NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL"
ARG ARG_NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=$ARG_NEXT_PUBLIC_API_URL
RUN echo "NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL"
# Build the project
RUN pnpm turbo build
GitHub Actions workflow (excerpt)
# .github/workflows/build-push-docker-image.yml
name: Build and push Docker frontend
on:
push:
branches: ['main']
workflow_dispatch:
env:
IMAGE_NAME: ${{ github.event.repository.name }}-frontend
# THIS: set build time env vars
NEXT_PUBLIC_SITE_URL: 'https://full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'
NEXT_PUBLIC_API_URL: 'https://api.full-stack-fastapi-template-nextjs.arm1.nemanjamitic.com'
jobs:
build:
runs-on: ubuntu-latest
steps:
# ...
- name: Build and push Docker image
uses: docker/build-push-action@v6
with:
context: ./frontend
file: ./frontend/Dockerfile
platforms: linux/amd64,linux/arm64
progress: plain
# THIS: set build time args
build-args: |
"ARG_NEXT_PUBLIC_SITE_URL=${{ env.NEXT_PUBLIC_SITE_URL }}"
"ARG_NEXT_PUBLIC_API_URL=${{ env.NEXT_PUBLIC_API_URL }}"
push: true
tags: ${{ secrets.DOCKER_USERNAME }}/${{ env.IMAGE_NAME }}:latest
package.json script (excerpt)
{
"scripts": {
"build": "turbo build",
"dev": "turbo dev",
"standalone": "turbo run standalone --filter web",
// THIS: set build time args
"docker:build:x86": "docker buildx build -f ./Dockerfile -t nemanjamitic/full-stack-fastapi-template-nextjs-frontend --build-arg ARG_NEXT_PUBLIC_SITE_URL='full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --build-arg ARG_NEXT_PUBLIC_API_URL='api.full-stack-fastapi-template-nextjs.local.nemanjamitic.com' --platform linux/amd64 ."
}
}
In this setup, the Next.js app requires NEXT_PUBLIC_SITE_URL and NEXT_PUBLIC_API_URL at build time. These values are inlined into the bundle during the build and cannot be changed later. Consequently:
- The Dockerfile must receive the corresponding
ARG_NEXT_PUBLIC_SITE_URLandARG_NEXT_PUBLIC_API_URLbuild arguments. - Leaving them undefined breaks the build (validation with Zod runs at both build and run time).
- Stripping the
NEXT_PUBLIC_prefix would also break the build if the variables are used in client code.
Because the values are baked into the image, the resulting Docker image can only be used in a single environment. Any runtime attempts to override NEXT_PUBLIC_SITE_URL or NEXT_PUBLIC_API_URL are ignored, as the variables have been replaced with string literals in the JavaScript bundle.
For multiple environments (staging, preview, testing, etc.) you would need separate images, each with its own configuration, build process, and registry storage—introducing significant overhead.
The community has highlighted this pain point:
- Better support for runtime environment variables #44628
- Docker image with NEXTPUBLIC env variables #17641
- Not possible to use different configurations in staging + production #22243
The solution: run‑time environment variables
The remedy is to avoid build‑time (immutable) variables altogether and read everything from the target environment at runtime. This also means steering clear of any NEXT_PUBLIC_* client variables.
To implement this approach, you must be aware of where and when each component runs (server vs. client, build vs. request). By loading configuration at request time, the same Docker image can be reused across all environments, with the appropriate values supplied via the container’s environment at startup.