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.
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 inHOSTNAME, which otherwise holds the container's own name.0.0.0.0makes it answer on every interface,localhostincluded, so a check from the shell of the App page works too.PORT=8080. JetDeploy sets noPORTvariable. Setting it in the image keeps it next toEXPOSE, and the container port of the process must match it..next/staticis not part of the standalone folder and has to be copied separately. Addpublic/the same way if your project has one.CMD ["node", "server.js"]. Node.js runs as PID 1 and receives theSIGTERMof 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, withoutnpm startor 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
- Create the App, add the remote and push (the steps are in Deploy a Vite single-page app with nginx).
- Add a process
webwith container port8080, external-facing. - Set
API_BASE_URLand your other server variables on the App page. - 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
PORTin the image. - A public value is empty in the browser.
.env.productionwas not pushed. Check it withgit ls-files .env.production. - A server variable shows up empty. The page was prerendered at build time. Make it dynamic, then push again.
next buildfails 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.