In my previous guide on containerizing Next.js apps, we encountered a notable challenge. Next.js offers two ways to handle environment variables:
- Variables accessible in the browser, prefixed with
NEXT_PUBLIC_
, are bundled during the build process, inlined and included in the client-side code. - Variables without this prefix, such as
MYVAR=1
, are only accessible to server-rendered components and API routes.
This distinction complicates the creation of a Docker image for Next.js apps that require dynamic, browser-accessible environment variables. When using an .env
file during the Docker build, the variables are statically included in the image, losing their dynamic nature. This is a limitation coming up from the deployment models of platforms like Vercel, which build and deploy code on-the-fly, unlike the pre-packaged approach required for Docker.
The Quick and Dirty Solution.
Given a .env.local
file with several variables, we can expose these through an API route. This method bypasses the need for the NEXT_PUBLIC_
prefix convention.
# .env.local
DOCKER_MY_ENV=hello
DOCKER_ANOTHER_ENV=world
We can then create an API route to serve these values:
// app/env/route.ts
// Disable caching
export const dynamic = "force-dynamic";
export async function GET(request: Request) {
const env = {
DOCKER_MY_ENV: process.env.DOCKER_MY_ENV,
DOCKER_ANOTHER_ENV: process.env.DOCKER_ANOTHER_ENV,
};
return Response.json(env);
}
Client components can fetch and display these values as follows:
// components/fetch-env.tsx
"use client";
import { useState, useEffect } from "react";
export const MyFetchComponent = () => {
const [env, setEnv] = useState({});
useEffect(() => {
fetch("/env")
.then((res) => res.json())
.then((data) => setEnv(data));
}, []);
return (
<div>
<pre>{JSON.stringify(env, null, 2)}</pre>
</div>
);
};
A More Consistent Approach.
While the above solution is functional, it includes an additional network request and it's not that versatile. A more seamless integrations involves passing variables from server components to client React components through a context provider.
First, define and export the environment variables using a helper function:
// env/env.ts
"use server";
export const getEnv = async () => ({
DOCKER_MY_ENV: process.env.DOCKER_MY_ENV,
DOCKER_ANOTHER_ENV: process.env.DOCKER_ANOTHER_ENV,
DOCKER_MY_INT: process.env.DOCKER_MY_INT,
DOCKER_MY_URL: process.env.DOCKER_MY_URL,
NEXT_PUBLIC_ANALYTICS_ID: process.env.NEXT_PUBLIC_ANALYTICS_ID,
});
The "use server"
directive will mark the call as a server-side function, in order to consume the helper in a Next.js layout the call should be asynchronous.
Next, create a context provider component:
// env/provider.tsx
"use client";
import { createContext, useContext, useEffect, useState } from "react";
import { getEnv } from "./env";
export const EnvContext = createContext({});
export const EnvProvider = ({
children,
}: Readonly<{
children: React.ReactNode;
}>) => {
const [env, setEnv] = useState({});
useEffect(() => {
getEnv().then((env) => {
setEnv(env);
});
}, []);
return <EnvContext.Provider value={env}>{children}</EnvContext.Provider>;
};
export const useEnv = () => {
return useContext(EnvContext);
};
The "use client"
directive is mandatory in order to keep track of any state changes. using the useState
hook. Upon mounting the provider will retrieve the values from the getEnv()
method and initialise the context.
Finally wrap your page contents with the <EnvProvider />
component:
// app/layout.tsx
import { EnvProvider } from "@/env/provider";
export default async function RootLayout({
children,
}: Readonly<{
children: React.ReactNode;
}>) {
return (
<html lang="en">
<body>
<EnvProvider>{children}</EnvProvider>
</body>
</html>
);
}
Client components can now access the environment variables:
// components/example.tsx
"use client";
import { useEnv } from "@/env/provider";
export const MyClientComponent = () => {
const env = useEnv();
return (
<div>
<h1>This is rendered in a client component.</h1>
<pre
style={{
padding: "1rem",
backgroundColor: "#f4f4f4",
border: "1px solid #ccc",
borderRadius: "5px",
margin: "1rem 0",
}}
>
{JSON.stringify(env, null, 2)}
</pre>
</div>
);
};
Building and Running the Docker Image.
Modify the Dockerfile from the previous tutorial by removing the environment variable bundling. Here's how to build and run the image:
FROM node:20-alpine AS base
# --- Dependencies ---
### Rebuild deps only when needed ###
FROM base AS deps
RUN apk add --no-cache libc6-compat git
RUN echo Building nextjs image with corepack
# Setup pnpm environment
ENV PNPM_HOME="/pnpm"
ENV PATH="$PNPM_HOME:$PATH"
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN pnpm install --frozen-lockfile --prefer-frozen-lockfile
# --- Builder ---
FROM base AS builder
RUN corepack enable
RUN corepack prepare pnpm@latest --activate
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN pnpm build
# --- Production runner ---
FROM base AS runner
# Set NODE_ENV to production
ENV NODE_ENV production
# Disable Next.js telemetry
# Learn more here: https://nextjs.org/telemetry
ENV NEXT_TELEMETRY_DISABLED 1
# Set correct permissions for nextjs user
# Don't run as root
RUN addgroup nodejs
RUN adduser -SDH nextjs
RUN mkdir .next
RUN chown nextjs:nodejs .next
# Automatically leverage output traces to reduce image size
# https://nextjs.org/docs/advanced-features/output-file-tracing
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
# Expose ports (for orchestrators and dynamic reverse proxies)
EXPOSE 3000
ENV PORT 3000
ENV HOSTNAME "0.0.0.0"
HEALTHCHECK --interval=30s --timeout=30s --start-period=5s --retries=3 CMD [ "wget", "-q0", "http://localhost:3000/health" ]
# Run the nextjs app
CMD ["node", "server.js"]
Here's how to build and run the image:
~ echo Building image
~ docker build --tag my-app-docker-env .
Run the application with a local .env
file:
docker run -p 3000:3000 --env-file .env.local my-app-docker-env
▲ Next.js 14.1.4
- Local: http://localhost:3000
- Network: http://0.0.0.0:3000
✓ Ready in 34ms
Test by stopping the container, modifying .env.local
, and restarting the application.
Enhancing Security.
To further secure your environment variables, consider using envsafe for validation and type safety. Install the package and adjust the env/env.ts
file accordingly along with the variable types:
pnpm add envsafe
// env/env.ts
"use server";
import { envsafe, str } from "envsafe";
export const getEnv = async () =>
envsafe({
DOCKER_MY_ENV: str(),
DOCKER_ANOTHER_ENV: str(),
DOCKER_MY_INT: str(),
DOCKER_MY_URL: str(),
});
Wrap up.
In conclusion, managing public environment variables in a Dockerized Next.js application requires a little effort to keep up with their dynamic nature. We explored two primary methods: serving variables through a Next.js API route and a more seamless integration using a context provider to pass variables from server components to client components. The latter approach, while slightly more complex, offers a more consistent and versatile solution. Finally, we covered the necessary changes to the Dockerfile to accommodate both strategies and highlighted the importance of security and type safety with tools like envsafe
.
Top comments (3)
As I understand, all variables from env will be passed to the context? Even those without NEXT_PUBLIC? If yes, this could be a potential security risk.
Not exactly, you can use another prefix to mark them and tools like
envsafe
or azod
parser to safely parse and cast the values.Thank you! your EnvProvider is really helpful for me