Web Development

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.

A

Alex Rivera

Senior DevOps Engineer specializing in containerization, CI/CD, and full-stack JavaScript frameworks.

7 min read14 views

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 or API_SECRET_KEY is only available on the server. You can access it in your server-side code (like API routes or getServerSideProps) via process.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 through process.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

Advertisement

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:

  1. Default Behavior: Next.js bakes NEXT_PUBLIC_ variables into your code at build time.
  2. The Conflict: This opposes Docker's "build once, run anywhere" philosophy.
  3. The Dockerfile: Always use a multi-stage Dockerfile for small, secure production images.
  4. 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.
  5. 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.

Tags

You May Also Like