Web Development

Docker & Next.js: Mastering Environment Variables

Unlock the power of Docker and Next.js! This practical guide demystifies environment variables, from local dev to production, with proven strategies.

A

Alex Miller

Full-stack developer and DevOps enthusiast specializing in containerized web applications.

7 min read14 views

Ever pushed a new feature, only to have it crash and burn in production because of a missing API key? Or maybe you've found yourself juggling a dozen different .env files, hoping you don't accidentally commit a secret to your Git repository. If this sounds familiar, you're not alone. Managing configuration across different environments—development, staging, and production—is a classic headache for developers.

The rise of containerization with Docker and the power of full-stack frameworks like Next.js has revolutionized how we build and ship applications. But when you bring these two powerhouses together, a new challenge emerges: how do you make them talk to each other about configuration? Next.js likes to know its environment variables when it builds, while Docker is designed to provide them when it runs. This mismatch can lead to brittle pipelines and frustrating deployment cycles.

This guide will demystify the process. We'll break down how Next.js and Docker handle environment variables, diagnose the core conflict, and provide you with three battle-tested strategies to manage your configuration like a pro. By the end, you'll have a clear, practical roadmap for building flexible, secure, and scalable Next.js applications with Docker.

Next.js Environment Variables: The Basics

Before diving into Docker, let's quickly recap how Next.js handles environment variables. It makes a crucial distinction between variables available on the server and those exposed to the browser.

  • Server-Side Variables: Any variable in your .env file (e.g., DATABASE_URL, API_SECRET_KEY) is available only in the Node.js environment. You can access it in server-side code like API routes, getServerSideProps, and middleware using process.env.DATABASE_URL.
  • Client-Side (Exposed) Variables: To expose a variable to the browser, you must prefix it with NEXT_PUBLIC_. For example, NEXT_PUBLIC_API_URL. Next.js bundles these variables into the client-side JavaScript, making them accessible via process.env.NEXT_PUBLIC_API_URL in your React components.

Next.js also has a specific file hierarchy for loading these variables:

  1. .env.local: Always loaded. Perfect for local secrets. Never commit this file.
  2. .env.development or .env.production: Loaded based on the environment (next dev vs. next start).
  3. .env: Loaded for all environments. Contains default values.

This system works beautifully for standard Next.js development. The complexity arises when we introduce Docker.

The Core Conflict: Build Time vs. Run Time

Here's the central problem: When you run next build, Next.js performs a static analysis of your code. It literally finds every instance of process.env.NEXT_PUBLIC_... and replaces it with its actual value at that moment. This is called inlining.

This is a feature, not a bug—it optimizes the client bundle. But it directly conflicts with the core principle of Docker: build once, run anywhere. In a proper Docker workflow, you build a single, generic image and then run it in different environments (dev, staging, prod) by injecting environment-specific configurations at run time.

If your NEXT_PUBLIC_API_URL is hardcoded into your JavaScript files during the docker build step, you can't change it later when you run the container. You'd have to rebuild the entire image just to point to a different API, which defeats the purpose of using containers for flexible deployments. Server-side variables don't suffer from this, as they are read from the environment when the server process starts. The real challenge lies with those public, client-side variables.

Strategy 1: The Docker Compose Method (For Development)

For local development, you want speed and convenience. Hot-reloading is a must. The easiest way to achieve this is by using Docker Compose to mount your local source code directly into the container.

Here's a typical docker-compose.yml for a development setup:

Advertisement

version: '3.8'
services:
  web:
    build:
      context: .
      dockerfile: Dockerfile.dev
    ports:
      - "3000:3000"
    volumes:
      - .:/app # Mount your project directory
      - /app/node_modules # Don't overwrite node_modules
      - /app/.next
    environment:
      - NODE_ENV=development
    

And a simple Dockerfile.dev:


FROM node:18-alpine
WORKDIR /app
COPY package*.json ./
RUN npm install
# The CMD will be run inside the container
CMD ["npm", "run", "dev"]
    

In this setup, you just create a .env.local file on your host machine. When you run docker-compose up, the volumes directive makes your entire project folder, including .env.local, available inside the container. The Next.js dev server (next dev) reads this file on the fly, and everything works seamlessly with hot-reloading.

Verdict: Perfect for local development. Absolutely not suitable for production, as it requires your source code and is inefficient.

Strategy 2: Build-Time Arguments (--build-arg)

This strategy embraces Next.js's build-time nature. You pass the environment variables to Docker at the moment you build the image. This is done using ARG in your Dockerfile and the --build-arg flag in your `docker build` command.

Here's how you'd modify your production Dockerfile:


# ... (previous build stages)

# --- Runner Stage ---
FROM node:18-alpine AS runner
WORKDIR /app

# 1. Declare the build arguments
ARG NEXT_PUBLIC_API_URL
ARG SOME_OTHER_PUBLIC_VAR

# 2. Set them as environment variables for the build process
ENV NEXT_PUBLIC_API_URL=$NEXT_PUBLIC_API_URL
ENV SOME_OTHER_PUBLIC_VAR=$SOME_OTHER_PUBLIC_VAR

# ... (copy built files from builder stage)
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next ./.next
# ...

# The 'next build' command (in the builder stage) will now see these ENVs

CMD ["npm", "start"]
    

You would then build your image like this:


docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.production.com \
  --build-arg SOME_OTHER_PUBLIC_VAR=prod_value \
  -t my-nextjs-app:prod .
    

Verdict: Simple to understand, but deeply flawed for most workflows. It bakes the configuration into the image, forcing you to create separate images for staging and production. This breaks the "build once, run anywhere" paradigm and can be a security risk, as secrets become part of the image layers.

Strategy 3: Runtime Variables (The Production Standard)

This is the holy grail: a single Docker image that can be configured at run time. This aligns perfectly with modern DevOps practices and tools like Kubernetes.

Server-Side Variables: The Easy Part

For server-side variables (e.g., DATABASE_URL), it's straightforward. The Next.js server (running via next start) is a Node.js process. It can read environment variables from its host environment at any time. You can pass them to your container with ease:


docker run -p 3000:3000 \
  -e DATABASE_URL="postgresql://user:pass@host:port/db" \
  -e API_SECRET_KEY="super-secret-key" \
  my-nextjs-app
    

Inside your API routes or getServerSideProps, process.env.DATABASE_URL will have the correct value. No tricks needed.

Client-Side Variables: The Mastery Part

This is where we solve the build-time inlining problem for NEXT_PUBLIC_ variables. The strategy is to use placeholders during the build and replace them just before the server starts.

Step 1: Use Placeholders in your .env file

During the build, you need to provide a value. We'll use a distinct placeholder format.


# .env.production
NEXT_PUBLIC_API_URL=__NEXT_PUBLIC_API_URL__
NEXT_PUBLIC_ANALYTICS_ID=__NEXT_PUBLIC_ANALYTICS_ID__
    

Step 2: Create an Entrypoint Script

This shell script will run when the container starts. It will find all the built JavaScript files and replace the placeholders with the actual environment variables provided to the container.

Create a file named entrypoint.sh:


#!/bin/sh

# Find all .js files in the .next/static directory
find /app/.next/static -type f -name "*.js" | while read file; do
  # Use the environment variables passed to the container to replace placeholders
  sed -i "s|__NEXT_PUBLIC_API_URL__|${NEXT_PUBLIC_API_URL}|g" "$file"
  sed -i "s|__NEXT_PUBLIC_ANALYTICS_ID__|${NEXT_PUBLIC_ANALYTICS_ID}|g" "$file"

  echo "Replaced placeholders in $file"
done

# Start the Next.js server
exec "$@"
    

Make sure this script is executable: chmod +x entrypoint.sh.

Step 3: Update Your Production Dockerfile

Finally, copy the entrypoint script into your image and set it as the ENTRYPOINT. The CMD will be passed as an argument to the entrypoint script.


# --- Runner Stage ---
FROM node:18-alpine AS runner
WORKDIR /app

ENV NODE_ENV=production

# ... (copy built files)

# Copy the entrypoint script
COPY --from=builder /app/entrypoint.sh .
RUN chmod +x ./entrypoint.sh

ENTRYPOINT ["./entrypoint.sh"]
CMD ["npm", "start"]
    

Now, you build your image once. Then, you can run it anywhere, providing the configuration at run time:


# For Staging
docker run -p 3000:3000 \
  -e NEXT_PUBLIC_API_URL="https://api.staging.com" \
  -e NEXT_PUBLIC_ANALYTICS_ID="STG-12345" \
  my-nextjs-app

# For Production
docker run -p 3000:3000 \
  -e NEXT_PUBLIC_API_URL="https://api.production.com" \
  -e NEXT_PUBLIC_ANALYTICS_ID="PROD-67890" \
  my-nextjs-app
    

Verdict: This is the most robust and flexible solution. It fully embraces containerization principles, making your application portable and easy to manage in automated CI/CD pipelines. It's the professional standard for production deployments.

Comparing the Strategies

Here's a quick summary to help you choose the right approach:

Strategy Best For Pros Cons
Docker Compose + Volume Mount Local Development Fast, simple, enables hot-reloading. Not for production, inefficient, requires source code.
Build-Time Args (`--build-arg`) Simple apps with unchanging public vars. Easy to implement, no extra scripts. Breaks "build once, run anywhere", requires image rebuild for config changes, security risk.
Runtime Vars + Entrypoint Script Staging & Production Flexible, secure, portable. Aligns with DevOps best practices. Slightly more complex setup with the entrypoint script.

Security & Best Practices

  • Never commit secrets: Always keep .env.local and other secret files out of Git by adding them to your .gitignore file. Commit a .env.example file to show what variables are needed.
  • Use a Secrets Manager: For production, inject environment variables from a secure source like AWS Secrets Manager, HashiCorp Vault, or your cloud provider's secret management service.
  • Minimize Exposed Variables: Be extremely cautious with what you prefix with NEXT_PUBLIC_. This data is fully visible in your users' browsers. If it's sensitive, it belongs on the server.

Conclusion: Build Once, Configure Anywhere

Managing environment variables in a Dockerized Next.js application doesn't have to be a source of frustration. By understanding the fundamental difference between build-time and run-time configuration, you can choose the right strategy for the job.

While quick-and-dirty methods work for local development, adopting the runtime variable strategy for production is a game-changer. It sets you up for a scalable, secure, and maintainable deployment process that will save you countless headaches down the line. You build your image once, and you can confidently run it anywhere, from your staging server to a complex Kubernetes cluster, simply by providing the right configuration at the right time.

Tags

You May Also Like