Solving Next.js Env Var Puzzles in Docker Containers
Struggling with undefined environment variables in your Dockerized Next.js app? This guide demystifies build-time vs. run-time vars and shows you how to manage them.
Alex Garcia
A full-stack developer specializing in TypeScript, Next.js, and containerization workflows.
You’ve meticulously crafted your Next.js application. It runs flawlessly on your local machine, pulling API keys and configuration settings from your .env.local
file like a charm. Now, it’s time to containerize it with Docker for consistent deployments. You build the image, run the container, and... crash. Or worse, your app runs, but every API call fails because your public environment variables are mysteriously undefined
.
If this scenario feels painfully familiar, you're not alone. The interaction between Next.js's build process and Docker's lifecycle can create a confusing puzzle. But don't worry, once you understand the core concepts, solving it becomes straightforward.
Let's unravel this mystery together.
The Core Conflict: Build-Time vs. Run-Time
The root of the problem lies in how Next.js handles different types of environment variables.
- Build-Time Variables (
NEXT_PUBLIC_*
): Any variable prefixed withNEXT_PUBLIC_
is designed for the browser. To make this possible, Next.js literally inlines its value into your JavaScript bundles during the build process (when you runnext build
). It replacesprocess.env.NEXT_PUBLIC_API_URL
with the actual string"https://api.example.com"
. - Run-Time Variables (Server-side): Variables without the prefix are only ever accessed on the server (in API routes,
getServerSideProps
, etc.). They are read from the environment on-demand when a request comes in, i.e., at run-time.
Now, consider Docker's lifecycle:
docker build
: This is where you runnext build
. It's a self-contained process that creates a static, unchangeable image.docker run
: This is when you start a container from that image. This is where your app's server process actually starts.
See the disconnect? If you pass an environment variable using docker run -e NEXT_PUBLIC_API_URL=...
, it's too late! The next build
command already ran during docker build
, and at that time, NEXT_PUBLIC_API_URL
was undefined. The JavaScript bundle was created without it.
Solution 1: The Build-Arg Approach (For Static Values)
The most direct way to solve the build-time problem is to provide the variables during the Docker build. This is perfect for values that don't change between environments, like a version number or a non-secret analytics key.
We use Docker's `ARG` and `ENV` instructions. `ARG` defines a variable that only exists during the build, which we can then pass into the build command.
Step 1: Update Your Dockerfile
Modify your Dockerfile
to accept build arguments and expose them to the Next.js build command.
# Dockerfile
# --- Base Stage ---
FROM node:18-alpine AS base
WORKDIR /app
# Install dependencies
COPY package*.json ./
RUN npm install
# --- Build Stage ---
FROM base AS builder
# Declare the build arguments
ARG NEXT_PUBLIC_API_URL
ARG NEXT_PUBLIC_ANALYTICS_ID
# Set them as environment variables for the 'next build' command
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_ANALYTICS_ID=$NEXT_PUBLIC_ANALYTICS_ID
COPY . .
RUN npm run build
# --- Production Stage ---
FROM base AS production
# Set the environment to production
ENV NODE_ENV=production
# Copy built assets from the builder stage
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package.json ./package.json
# Expose the port the app runs on
EXPOSE 3000
# Start the app
CMD ["npm", "start"]
Step 2: Build Your Image
Now, pass the values when you build the image using the --build-arg
flag.
docker build \
--build-arg NEXT_PUBLIC_API_URL=https://api.production.com \
--build-arg NEXT_PUBLIC_ANALYTICS_ID=G-XYZ123 \
-t my-next-app .
Pros: Simple and effective for build-time variables.
Cons: The resulting Docker image is now hardcoded for a specific environment. You need to rebuild the image for staging, production, etc. This is not ideal for secrets or configs that change frequently.
Solution 2: The Runtime Config Approach (The Flexible Way)
What if you want one image that you can promote across all environments (Dev, Staging, Prod), configuring it at runtime? This is the "Build once, deploy anywhere" philosophy, and it's the most robust solution.
Next.js offers a way out through `publicRuntimeConfig` in your next.config.js
file. This feature allows you to expose variables that are determined when the server starts, not when it's built.
Step 1: Update next.config.js
Configure your `next.config.js` to read from `process.env` at runtime.
// next.config.js
/** @type {import('next').NextConfig} */
const nextConfig = {
reactStrictMode: true,
// Make sure this is enabled for the standalone output mode in Docker
output: 'standalone',
// publicRuntimeConfig will be available on both server and client
publicRuntimeConfig: {
// Will be available on process.env.NEXT_PUBLIC_API_URL
apiUrl: process.env.NEXT_PUBLIC_API_URL,
analyticsId: process.env.NEXT_PUBLIC_ANALYTICS_ID,
},
};
module.exports = nextConfig;
Important: With this setup, Next.js makes these variables available at runtime, but you need to access them differently.
Step 2: Access the Variables in Your Code
Instead of `process.env`, you'll use `getConfig` from `next/config`.
// src/lib/api.js
import getConfig from 'next/config';
// Get the runtime config
const { publicRuntimeConfig } = getConfig();
const API_URL = publicRuntimeConfig.apiUrl;
export async function fetchData() {
const res = await fetch(`${API_URL}/items`);
// ...
}
Step 3: Simplify Your Dockerfile
Your Dockerfile
becomes much simpler now. You no longer need the `ARG` instructions for your public variables.
# Dockerfile (Runtime Config Version)
# --- Base Stage ---
FROM node:18-alpine AS base
WORKDIR /app
# --- Dependencies Stage ---
FROM base AS deps
COPY package*.json ./
RUN npm install
# --- Build Stage ---
FROM base AS builder
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# No need for build-time env vars here!
RUN npm run build
# --- Production Stage ---
FROM node:18-alpine AS production
ENV NODE_ENV=production
WORKDIR /app
# Copy from the builder stage, utilizing the 'standalone' output
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
EXPOSE 3000
# Run the server
CMD ["node", "server.js"]
Note: This example uses Next.js's standalone
output mode, which is highly recommended for optimized Docker images.
Step 4: Run the Container with Env Vars
Now, you can build the image once and configure it on the fly with `docker run`'s `-e` flag.
# Build once
docker build -t my-next-app-flexible .
# Run for Staging
docker run -p 3000:3000 \
-e NEXT_PUBLIC_API_URL=https://api.staging.com \
-e NEXT_PUBLIC_ANALYTICS_ID=G-STAGING \
my-next-app-flexible
# Run for Production
docker run -p 3000:3000 \
-e NEXT_PUBLIC_API_URL=https://api.production.com \
-e NEXT_PUBLIC_ANALYTICS_ID=G-PROD \
my-next-app-flexible
What About Server-Side Variables?
This is the easy part! Since server-side variables (e.g., `DATABASE_URL`, `API_SECRET_KEY`) are read at run-time by default, they just work. Simply pass them using the `-e` flag in `docker run` or your `docker-compose.yml` file, and you can access them directly via `process.env.API_SECRET_KEY` in your server-side code (`getServerSideProps`, API routes).
docker run -p 3000:3000 \
-e API_SECRET_KEY=super-secret-value-from-vault \
-e NEXT_PUBLIC_API_URL=https://api.production.com \
my-next-app-flexible
Putting It All Together with Docker Compose
In a real project, you'll likely use `docker-compose`. Here’s how you can manage both build-time and run-time variables.
# docker-compose.yml
version: '3.8'
services:
webapp:
build:
context: .
dockerfile: Dockerfile
args:
# Build-time args for things that won't change
- NEXT_PUBLIC_APP_VERSION=1.0.0
image: my-next-app-final
ports:
- "3000:3000"
environment:
# Run-time variables that change per environment
- NODE_ENV=production
- NEXT_PUBLIC_API_URL=https://api.dev.com
- DATABASE_URL=postgres://user:pass@db:5432/mydb
Conclusion: The "Aha!" Moment
The puzzle of Next.js environment variables in Docker isn't about a bug; it's about aligning two different lifecycles. The key is to ask yourself: "When does this variable need to be known?"
- During the build? Use Docker's
ARG
and build with--build-arg
. - When the server starts? Use
publicRuntimeConfig
innext.config.js
and pass the variable with-e
at runtime. - For server-side code only? Just pass it with
-e
at runtime. It's that simple.
By choosing the right strategy for each variable, you can build flexible, portable, and correctly configured Next.js applications that are a joy to deploy with Docker. Happy containerizing!