Next.js standalone builds: build-time and runtime variables

May 9, 2026
Tutorial

Next.js reads environment variables at two different moments. NEXT_PUBLIC_* values are copied into the JavaScript bundle by next build. Everything else is read by the server when a request comes in. On JetDeploy the build does not see the App's environment variables, so the two kinds have to come from two different places.

This tutorial sets up a Next.js App Router project with output: "standalone", a small production image, and both kinds of variables in the right place.

next build.env.productioncommitted, public valuesinlinedJavaScript bundleNEXT_PUBLIC_* fixed hereeach requestApp env varsset in JetDeploy, secretprocess.envServer codedynamic pages, route handlersPage in the browserboth kinds of values
Public values are frozen into the bundle at build time; server values are read on every request.

1. Standalone output

// next.config.mjs
const nextConfig = {
  output: "standalone",
};

export default nextConfig;

With standalone, next build writes .next/standalone/server.js plus only the node_modules files the server actually imports. The runtime image needs no npm install.

2. The Dockerfile

FROM node:24-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

FROM node:24-alpine AS build
WORKDIR /app
ENV NEXT_TELEMETRY_DISABLED=1
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

FROM node:24-alpine
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 HOSTNAME=0.0.0.0 PORT=8080
COPY --from=build --chown=node:node /app/.next/standalone ./
COPY --from=build --chown=node:node /app/.next/static ./.next/static
USER node
EXPOSE 8080
CMD ["node", "server.js"]
  • HOSTNAME=0.0.0.0. The standalone server binds to the address in HOSTNAME, which otherwise holds the container's own name. 0.0.0.0 makes it answer on every interface, localhost included, so a check from the shell of the App page works too.
  • PORT=8080. JetDeploy sets no PORT variable. Setting it in the image keeps it next to EXPOSE, and the container port of the process must match it.
  • .next/static is not part of the standalone folder and has to be copied separately. Add public/ the same way if your project has one.
  • CMD ["node", "server.js"]. Node.js runs as PID 1 and receives the SIGTERM of every deploy directly. The standalone server handles it by itself: it stops accepting connections, finishes the requests in flight and exits. Keep it that way, without npm start or a shell script in between, which would receive the signal in its place.

3. Public values: commit them

NEXT_PUBLIC_* values end up in files that every visitor downloads. They are public by definition, so committing them costs nothing. Put them in .env.production, which next build reads, and make sure git tracks it: the .gitignore of a new Next.js project ignores every .env* file, and JetDeploy builds only what you push.

# .gitignore
.env*
!.env.production

The file itself:

# .env.production
NEXT_PUBLIC_SITE_NAME=Acme Store
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=acme.example

Setting NEXT_PUBLIC_SITE_NAME on the App page changes nothing in the browser. The value was fixed when the bundle was built, before the App's variables existed. We checked: an image built with Acme Store kept showing Acme Store when started with NEXT_PUBLIC_SITE_NAME=Changed.

Keep .env.local and any file with secrets in .dockerignore, so it never reaches the build:

node_modules
.next
.git
.env*.local

4. Server values: set them on the App

API keys, database URLs and internal service addresses go in the App's environment variables. Read them in server code at request time:

// app/page.js
import { connection } from "next/server";

export default async function Home() {
  await connection();
  return (
    <main>
      <h1>{process.env.NEXT_PUBLIC_SITE_NAME}</h1>
      <p>API: {process.env.API_BASE_URL}</p>
    </main>
  );
}

await connection() marks the page as dynamic. Without it, Next.js may prerender the page at build time, when API_BASE_URL does not exist, and serve that frozen HTML forever. The same applies to route handlers and server actions that read process.env: make sure they run per request.

Variable Where it lives When it is read Change takes effect
NEXT_PUBLIC_* .env.production in the repo next build next push
server-only App environment variables each request next Apply

5. Deploy

  1. Create the App, add the remote and push (the steps are in Deploy a Vite single-page app with nginx).
  2. Add a process web with container port 8080, external-facing.
  3. Set API_BASE_URL and your other server variables on the App page.
  4. Click Deploy.

To call another App of the same organization from server code, use its internal address, http://<app>-<process>:<port>. That request never leaves the private network and never goes through the edge.

Troubleshooting

  • The deploy fails and nothing listens on the port. Check the process's container port against PORT in the image.
  • A public value is empty in the browser. .env.production was not pushed. Check it with git ls-files .env.production.
  • A server variable shows up empty. The page was prerendered at build time. Make it dynamic, then push again.
  • next build fails with a missing variable. Some code reads a server variable at import time. Move the read inside the function that needs it.

Next step

The build has 1200 seconds. If statically generated pages push a build toward that limit, render them on demand instead of at build time. The Build section of the docs lists what else the build can and cannot see.

Tested locally with Next.js 16.3, React 19.3 and Node.js 24 (Docker build, then the image run twice with different environment variables). The JetDeploy steps follow the documentation.

Related Articles

All posts