Runtime environment variables in Next.js - build reusable Docker images

Published: (December 14, 2025 at 03:17 AM EST)
4 min read
Source: Dev.to

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_URL and ARG_NEXT_PUBLIC_API_URL build 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:

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.

Back to Blog

Related posts

Read more »