Tutorial

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

Build a small Next.js standalone image and keep NEXT_PUBLIC values in the repository and server secrets in the App environment, read at request time.

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
// 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

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.

Build time

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
# .gitignore
.env*
!.env.production

The file itself:

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

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

.dockerignore
node_modules
.next
.git
.env*.local
Runtime

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
// 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.

VariableBuild timeNEXT_PUBLIC_*Runtimeserver-only
Where it lives.env.production in the repoApp environment variables
When it is readnext buildeach request
Change takes effectnext pushnext 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

SymptomFix
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.

Deploy it on JetDeploy

Push your code with git: JetDeploy builds it from your Dockerfile and serves it over HTTPS with zero-downtime deploys, next to managed databases.

Related Articles

All posts