🚀 Executive Summary
TL;DR: Next.js applications in Docker frequently encounter ‘undefined’ environment variables because `next build` hardcodes `NEXT_PUBLIC_` variables at build-time, conflicting with Docker’s runtime injection. The article provides solutions ranging from standard build-time `NEXT_PUBLIC_` usage to a flexible runtime configuration script or a custom server for dynamic environment variable access.
🎯 Key Takeaways
- Next.js hardcodes `NEXT_PUBLIC_` prefixed environment variables into static JavaScript bundles during `next build`, creating a fundamental build-time vs. runtime disconnect with Docker’s variable injection.
- The ‘Enterprise’ solution for Dockerized Next.js apps involves an `entrypoint.sh` script dynamically generating a `public/env-config.js` file at container startup, making runtime environment variables accessible via `window.env` on the client.
- For maximum control over server-side runtime variables and complex integrations, a custom server (e.g., Express.js) can be used, allowing `process.env` to function as expected at runtime, albeit with added complexity and loss of some Vercel optimizations.
Tired of `undefined` environment variables in your Dockerized Next.js app? A senior engineer’s guide to fixing the dreaded ‘undefined’ env var, from quick fixes to enterprise-grade, runtime solutions.
My Next.js App’s Environment Variables Are ‘undefined’ in Docker. Again.
I still remember the 3 AM incident. We were pushing a hotfix for our main e-commerce platform. Everything worked flawlessly in staging. We pushed to production, popped the champagne, and then the PagerDuty alerts started screaming. The marketing analytics dashboard was flatlining. A frantic `docker exec` into the new container on `web-app-prod-01` showed the environment variable for the new tracking service was there, loud and clear. Yet, the app was acting like it was completely `undefined`. Why? Because the build was done hours ago, in a CI pipeline that had no idea about the variable we just added to our production ECS task definition. If this sounds familiar, grab a coffee. Let’s fix this for good.
The “Why”: The Build-Time vs. Runtime Disconnect
This isn’t a bug; it’s a feature you’re fighting against. When you run next build, Next.js performs a critical optimization: it scans your code for any environment variables prefixed with NEXT_PUBLIC_ and literally replaces them with their value at that moment. It hardcodes them into the static JavaScript bundles. The goal is to avoid shipping your entire environment to the client.
The problem is that a Docker container is designed to be a generic artifact. You build an image once and then run it in multiple environments (dev, staging, prod) by injecting environment-specific variables at runtime. See the clash? Your Next.js build, frozen in time, knows nothing about the variables you’re passing to the `docker run` command hours or days later.
Heads Up: This is a fundamental concept. Your Docker image’s build environment and its runtime environment are two separate universes. Anything needed in the browser must be known when
next buildis executed, unless you use one of the strategies below.
Solution 1: The “Quick Fix” – The Standard `NEXT_PUBLIC_` Method
This is the textbook solution and works perfectly if your variables don’t change after the build. You simply prefix any variable you need on the client-side with NEXT_PUBLIC_ in your .env files.
Your .env.local might look like this:
# This is available on the server AND the client
NEXT_PUBLIC_API_URL=https://api.dev.techresolve.io
# This is ONLY available on the server
DATABASE_URL=postgres://user:pass@dev-db-01.internal:5432/maindb
Your Dockerfile’s build stage needs access to these variables. You would typically pass them in during the CI/CD build step.
# Dockerfile (Build Stage)
FROM node:18-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm install
COPY . .
# Pass build-time args to the build command
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}
RUN npm run build
# ... rest of the Dockerfile
When to use this: When your variables are truly static per environment and you’re building a separate Docker image for each one (e.g., `my-app:staging`, `my-app:production`). It’s simple and follows the official docs, but it lacks runtime flexibility.
Solution 2: The “Enterprise” Fix – Runtime Configuration
This is my preferred method for scalable, flexible deployments. We accept that the build is static, but we fetch our configuration at runtime, just before the application starts. The idea is to have the Docker container create a JavaScript configuration file on startup.
Step 1: Create a placeholder in your `public` directory.
Create a file `public/env-config.js`:
window.env = {
// This object will be populated by the container at runtime
};
Step 2: Load this script in your application’s layout.
In your main `app/layout.tsx`, add a script tag before the Next.js scripts.
import Script from 'next/script';
export default function RootLayout({ children }: { children: React.ReactNode }) {
return (
<html lang="en">
<head>
<Script src="/env-config.js" strategy="beforeInteractive" />
</head>
<body>{children}</body>
</html>
);
}
Step 3: Create an entrypoint script for your Docker container.
Create a file named `entrypoint.sh`:
#!/bin/sh
# Recreate config file
rm -f /app/public/env-config.js
touch /app/public/env-config.js
# Add assignment
echo "window.env = {" >> /app/public/env-config.js
# Read each environment variable and add it to the config
echo " NEXT_PUBLIC_API_URL: \"$NEXT_PUBLIC_API_URL\"," >> /app/public/env-config.js
echo " NEXT_PUBLIC_ANALYTICS_KEY: \"$NEXT_PUBLIC_ANALYTICS_KEY\"," >> /app/public/env-config.js
echo "}" >> /app/public/env-config.js
# Start the Next.js server
exec "$@"
Step 4: Update your Dockerfile to use the entrypoint.
# In your final stage
# ...
COPY --from=builder /app/public /app/public
COPY --from=builder /app/node_modules /app/node_modules
COPY --from=builder /app/.next /app/.next
# Add the entrypoint script
COPY entrypoint.sh /app/entrypoint.sh
RUN chmod +x /app/entrypoint.sh
ENTRYPOINT ["/app/entrypoint.sh"]
CMD ["npm", "start"]
Now, when you run your container, you can pass the variables and they will be available on `window.env` anywhere in your client-side code. This is how we run single-image deployments across our entire infrastructure.
Pro Tip: You can even automate the `entrypoint.sh` script to loop through all variables prefixed with `NEXT_PUBLIC_` so you don’t have to add them manually each time. This is the truly “set it and forget it” approach.
Solution 3: The “Nuclear” Option – Custom Server
Sometimes you need even more control, especially over server-side variables that must be dynamic at runtime. In this case, you can opt-out of the standard next start and use a custom server (like Express.js).
This approach gives you a traditional server environment where `process.env` works exactly as you’d expect at runtime, for every request. However, you lose some Vercel-specific optimizations and add another layer of complexity to maintain.
Here’s a barebones `server.js`:
const { createServer } = require('http');
const { parse } = require('url');
const next = require('next');
const dev = process.env.NODE_ENV !== 'production';
const app = next({ dev });
const handle = app.getRequestHandler();
// This runs when the server starts, reading runtime env vars
const API_KEY = process.env.SECRET_API_KEY;
console.log(`My secret key is: ${API_KEY ? 'loaded' : 'MISSING!'}`);
app.prepare().then(() => {
createServer((req, res) => {
const parsedUrl = parse(req.url, true);
// You can now use runtime variables in your server logic here
handle(req, res, parsedUrl);
}).listen(3000, (err) => {
if (err) throw err;
console.log('> Ready on http://localhost:3000');
});
});
When to use this: When you have complex server-side needs, middleware, or require runtime access to secret keys that can’t be exposed during the build at all. This is common in legacy enterprise systems or when integrating with non-standard authentication.
Quick Comparison
| Method | Complexity | Flexibility | Best For |
|---|---|---|---|
1. Standard NEXT_PUBLIC_ |
Low | Low (Build-time only) | Simple projects, Vercel deployments, where envs are static. |
| 2. Runtime Config Script | Medium | High (Runtime client vars) | Most containerized (Docker/Kubernetes) deployments. The sweet spot. |
| 3. Custom Server | High | Maximum (Full runtime control) | Complex server-side needs, enterprise integrations, maximum control. |
So, next time you see `undefined`, don’t just blame Docker or Next.js. Understand the lifecycle of your application from build to run, and choose the strategy that gives your team the flexibility it needs. For my team, that’s almost always Solution 2. It saved our 3 AM deployment, and it’ll probably save yours too.
🤖 Frequently Asked Questions
âť“ Why do Next.js environment variables appear ‘undefined’ in Docker containers?
Next.js hardcodes `NEXT_PUBLIC_` variables during the `next build` process, embedding their values directly into the static bundles. This conflicts with Docker’s design, which injects environment-specific variables at runtime, leading to a disconnect where the built application doesn’t recognize variables passed later.
âť“ How do the different Next.js environment variable solutions compare in terms of flexibility and complexity for Docker deployments?
The standard `NEXT_PUBLIC_` method is low complexity but low flexibility (build-time only). The Runtime Config Script offers medium complexity and high flexibility, ideal for most containerized deployments by providing runtime client variables. The Custom Server is high complexity but provides maximum flexibility with full runtime control for both client and server variables.
âť“ What is a common pitfall when implementing the ‘Enterprise’ runtime configuration for Next.js environment variables in Docker?
A common pitfall is manually listing each `NEXT_PUBLIC_` variable in the `entrypoint.sh` script. The solution is to automate the script to loop through all environment variables prefixed with `NEXT_PUBLIC_` and dynamically add them to the `window.env` object, making it more scalable and maintainable.
Leave a Reply