The Complete Guide to Docker & Next.js Env Vars (2024)
Master Next.js environment variables in Docker! This 2024 guide covers build-time vs. runtime vars, Docker Compose, and best practices for secure, scalable apps.
Alex Rivera
Senior DevOps Engineer specializing in containerization, CI/CD, and full-stack JavaScript frameworks.
If you've ever tried to deploy a Next.js application with Docker, you've likely hit a wall. Everything works fine locally, but as soon as you try to manage different environments like staging and production, things get... complicated. The culprit? Environment variables.
The way Next.js handles environment variables and the way Docker is designed to work can feel like they're at odds. But don't worry. By the end of this guide, you'll have a clear, modern (2024-proof!) strategy to manage your variables seamlessly, following the best practice of building your Docker image once and running it anywhere.
Understanding Next.js Env Vars: The Core Conflict
First, let's break down the fundamental issue. Next.js is smart about environment variables, but it makes one big assumption: it knows their values at build time.
Server-side vs. Browser-exposed Variables
Next.js separates variables into two buckets:
- Server-side only: Any variable like
DB_PASSWORD
orAPI_SECRET_KEY
is only available on the server. You can access it in your server-side code (like API routes orgetServerSideProps
) viaprocess.env.DB_PASSWORD
. These are secure and never sent to the client. - Browser-exposed: To expose a variable to the browser, you must prefix it with
NEXT_PUBLIC_
. For example,NEXT_PUBLIC_API_URL
. Next.js then makes this available in your client-side code throughprocess.env.NEXT_PUBLIC_API_URL
.
The "Build-Time" Dilemma
Here’s the critical part. When you run next build
, Next.js performs a static analysis of your code. It finds all references to process.env.NEXT_PUBLIC_*
variables and replaces them with their actual values at that moment. The value for NEXT_PUBLIC_API_URL
gets hardcoded directly into the JavaScript bundles that are sent to the browser.
This is a problem for Docker. The core principle of containerization is to build an image once and then promote that exact same image through different environments (dev → staging → production). Each environment needs a different NEXT_PUBLIC_API_URL
. If the URL is baked into the image during the build step, you can't change it without rebuilding the entire image for each environment, which defeats the purpose!
Dockerizing Your Next.js App the Right Way
Before we solve the variable problem, let's ensure our Docker setup is solid. A multi-stage Dockerfile is non-negotiable for a production-grade Next.js app.
Why? It creates a small, optimized final image by separating the build environment (with all its dev dependencies) from the lean runtime environment.
Here’s what a good Dockerfile
looks like:
# Stage 1: Builder
# This stage installs dependencies and builds the Next.js app
FROM node:20-alpine AS builder
WORKDIR /app
# Copy package.json and lock files
COPY package*.json ./
# Install dependencies
RUN npm install
# Copy the rest of the application source code
COPY . .
# Build the Next.js app
# Any build-time env vars would be needed here
RUN npm run build
# ---
# Stage 2: Runner
# This stage creates the final, lean image
FROM node:20-alpine AS runner
WORKDIR /app
# Copy only the necessary files from the builder stage
COPY --from=builder /app/package.json /app/package.json
COPY --from=builder /app/.next /app/.next
COPY --from=builder /app/public /app/public
COPY --from=builder /app/node_modules /app/node_modules
# Expose the port the app runs on
EXPOSE 3000
# The command to start the app
# This will run with runtime environment variables
CMD ["npm", "start"]
This setup gives us a clean separation. The builder
stage does the heavy lifting, and the runner
stage contains only what's needed to run the already-built application.
The Solution: Separating Build vs. Runtime Vars
Now, let's tackle the environment variable problem head-on. The key is to understand that server-side variables are already runtime-ready, but browser-facing NEXT_PUBLIC_
variables are not.
Handling True Build-Time Variables
Sometimes, you actually do want a variable at build time (e.g., a feature flag that changes the built code). For these rare cases, you can use Docker's build arguments.
In your Dockerfile
(in the `builder` stage):
# ... inside builder stage
ARG FEATURE_FLAG
ENV NEXT_PUBLIC_FEATURE_FLAG=$FEATURE_FLAG
RUN npm run build
And you'd build the image like this:
docker build --build-arg FEATURE_FLAG=true -t my-next-app .
This correctly bakes NEXT_PUBLIC_FEATURE_FLAG
into the client bundles. Use this only for variables that must be known at build time.
The Real Goal: Runtime Public Variables
For variables like API endpoints, we need them at runtime. Since Next.js doesn't support this for NEXT_PUBLIC_
variables out of the box, we need a clever workaround. The best and most robust pattern is to fetch this configuration from the client-side.
The Strategy: Create an API route that reads server-side (runtime) environment variables and exposes a safe subset of them to the client.
Step 1: Create the Configuration API Route
Create a file at pages/api/config.ts
(or app/api/config/route.ts
if using App Router):
// pages/api/config.ts
import type { NextApiRequest, NextApiResponse } from 'next';
export default function handler(
req: NextApiRequest,
res: NextApiResponse
) {
// These are read at RUNTIME on the server
const config = {
apiUrl: process.env.API_URL,
googleAnalyticsId: process.env.GOOGLE_ANALYTICS_ID,
};
res.status(200).json(config);
}
Notice we are using API_URL
, not NEXT_PUBLIC_API_URL
. These are standard server-side variables that Docker will provide at runtime.
Step 2: Fetch the Configuration in Your App
In your main app layout file (_app.tsx
), you can fetch this configuration and provide it to your entire application using React Context.
// A simple context to hold our runtime config
import { createContext, useContext, ReactNode } from 'react';
export const AppConfigContext = createContext<any>(null);
export const useAppConfig = () => useContext(AppConfigContext);
// In your _app.tsx (for Pages Router)
import App from 'next/app';
import type { AppContext, AppProps } from 'next/app';
function MyApp({ Component, pageProps, appConfig }: AppProps & { appConfig: any }) {
return (
<AppConfigContext.Provider value={appConfig}>
<Component {...pageProps} />
</AppConfigContext.Provider>
);
}
// Fetch the config on the server before the app renders
MyApp.getInitialProps = async (appContext: AppContext) => {
const appProps = await App.getInitialProps(appContext);
const baseUrl = process.browser ? '' : `http://localhost:${process.env.PORT || 3000}`;
const res = await fetch(`${baseUrl}/api/config`);
const appConfig = await res.json();
return { ...appProps, appConfig };
};
export default MyApp;
Step 3: Use the Runtime Configuration
Now, in any component, you can access your runtime variables cleanly:
import { useAppConfig } from '../path/to/context';
function MyComponent() {
const config = useAppConfig();
async function fetchData() {
// This URL changes based on the container's environment!
const response = await fetch(`${config.apiUrl}/data`);
// ...
}
return <div>API URL is: {config.apiUrl}</div>;
}
With this pattern, we've completely bypassed the build-time injection problem for our public variables!
Putting It All Together with Docker Compose
Docker Compose makes managing runtime environments trivial. You can define your variables for each environment in a separate file.
Here’s a docker-compose.yml
:
version: '3.8'
services:
next-app:
build:
context: .
dockerfile: Dockerfile
image: my-next-app # Use the same image for all environments
ports:
- "3000:3000"
# Load environment variables from a file
env_file:
- .env.production
And your .env.production
file would contain the runtime variables:
# .env.production
# Server-side vars for Next.js server
DB_HOST=prod-db.example.com
DB_USER=prod_user
# Vars to be exposed via our /api/config endpoint
API_URL=https://api.production.com
GOOGLE_ANALYTICS_ID=G-ABC123XYZ
# The port the app will run on inside the container
PORT=3000
Now, you can simply run docker-compose up
. Docker will pass the variables from .env.production
into the container. Your Next.js server starts, reads them, and your /api/config
endpoint serves the correct values to the client, all without a rebuild.
Key Takeaways
Managing environment variables in a Dockerized Next.js app doesn't have to be a headache. By embracing this modern workflow, you align with DevOps best practices and create a truly portable application.
Remember these key points:
- Default Behavior: Next.js bakes
NEXT_PUBLIC_
variables into your code at build time. - The Conflict: This opposes Docker's "build once, run anywhere" philosophy.
- The Dockerfile: Always use a multi-stage Dockerfile for small, secure production images.
- The Solution: Don't rely on
NEXT_PUBLIC_
for variables that change between environments. Instead, create a simple/api/config
endpoint to serve runtime variables to your client-side code. - The Orchestration: Use Docker Compose with
env_file
to inject environment-specific configurations into your container at runtime.
By adopting this approach, you gain the flexibility to deploy the same artifact to any environment with confidence, knowing your configuration will be correct every single time.