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 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.productionThe file itself:
# .env.production
NEXT_PUBLIC_SITE_NAME=Acme Store
NEXT_PUBLIC_PLAUSIBLE_DOMAIN=acme.exampleKeep .env.local and any file with secrets in .dockerignore, so it never reaches the build:
node_modules
.next
.git
.env*.local4 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 | Build timeNEXT_PUBLIC_* | Runtimeserver-only |
|---|---|---|
| Where it lives | .env.production in the repo | App environment variables |
| When it is read | next build | each request |
| Change takes effect | next push | 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
| Symptom | Fix |
|---|---|
| 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.