Next.js with Public Environment Variables in Docker
The code for this guide is available on GitHub
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.
Quick Start
You can clone the full tutorial from GitHub or grab the code locally with the following commands:
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.
DOCKER_MY_ENV=hello
DOCKER_ANOTHER_ENV=world
We can then create an API route to serve these values:
// 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:
"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:
"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:
"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:
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:
"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
"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`
.