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.
Alex Miller
Full-stack developer and DevOps enthusiast specializing in containerized web applications.
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 usingprocess.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 viaprocess.env.NEXT_PUBLIC_API_URL
in your React components.
Next.js also has a specific file hierarchy for loading these variables:
.env.local
: Always loaded. Perfect for local secrets. Never commit this file..env.development
or.env.production
: Loaded based on the environment (next dev
vs.next start
)..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:
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.