PVR Tech Studio

Deploying the Next.js Edition

This edition server-side renders, so it needs a Node process — not a static file host.

4 min read
Updated August 17, 2026

Overview

You have three options, in increasing order of control:

OptionGood forWhat you provide
Managed platform (Vercel, Netlify, Render, Railway, Fly)Fastest path; zero infrastructureA repo. The platform detects Next and runs build + start.
Container (Cloud Run, ECS, Kubernetes, any Docker host)Portability, reproducible buildsThe Dockerfile shown below.
Plain Node server (VPS, bare metal)Full control, existing infrastructureNode 20+, a process manager, and a TLS terminator in front.

All three run the same server. Nothing about the app is host-specific — no adapters, no platform plugins, no vendor SDK.

Architecture & files

next.config.ts sets output: 'standalone', so npm run build emits a self-contained server at .next/standalone/ that carries only the dependencies actually traced from your imports. That folder is roughly an order of magnitude smaller than a full node_modules, and it needs no install step to run.

Two directories are deliberately excluded from the standalone output, because they are served from disk rather than imported:

Must be copied beside the serverWhy
.next/staticThe client bundles, hashed and immutable.
publicAvatars, flags, illustrations, favicon.

Forgetting either produces a site that renders but has no styling or images — the single most common standalone deployment mistake.

The build also runs codegen first (wired to prebuild), which compiles the translation message bundles used by the localized routes.

Usage

Managed platform

Point the platform at the project, set the environment variables from the configuration table, and let it run npm run build and npm start. Set the Node version to 20 or newer.

Plain Node server

npm ci
npm run build
npm start          # honours PORT; defaults to 3000

Put a reverse proxy (nginx, Caddy) in front for TLS, and keep the process under a supervisor (systemd, pm2) so it restarts on failure.

Container

A production Dockerfile, using the standalone output:

FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
 
FROM node:22-alpine AS build
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Values baked into the client bundle must be present NOW, not at runtime.
ARG NEXT_PUBLIC_SITE_URL
ARG NEXT_PUBLIC_API_BASE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL \
    NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL \
    NEXT_TELEMETRY_DISABLED=1
RUN npm run build
 
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production PORT=8080 HOSTNAME=0.0.0.0
RUN addgroup -g 1001 -S nodejs && adduser -u 1001 -S nextjs -G nodejs
COPY --from=build --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=build --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=build --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 8080
CMD ["node", "server.js"]

Build and run it:

docker build -t luminaux-next --build-arg NEXT_PUBLIC_SITE_URL=https://example.com .
docker run -p 8080:8080 luminaux-next

HOSTNAME=0.0.0.0 matters: the default binding is not reachable from outside the container.

Configuration & customization

Copy .env.example to .env.local and fill it in. Which tier a variable belongs to decides where you set it, and getting it wrong fails silently:

VariableTierNotes
NEXT_PUBLIC_API_BASE_URLBuildREST backend. Defaults to /api.
NEXT_PUBLIC_SITE_URLBuildAbsolute public origin. Used by sitemap.xml, robots.txt and the OpenGraph tags — if it is wrong or missing, those emit localhost URLs.
NEXT_PUBLIC_GTM_IDBuildGoogle Tag Manager container. Unset means analytics is fully inert: no script injected, nothing sent.
NEXT_PUBLIC_BASE_PATHBuildSet only when serving from a subpath; mirror it with basePath in next.config.ts.
AUTH_REQUIREDRuntimetrue requires a session for every in-shell route. Off by default so the app is browsable out of the box.

Build tier means the value is compiled into the JavaScript bundle. Only the NEXT_PUBLIC_ prefix is exposed to the browser at all, and setting one of those on an already-built image or a running container has no effect — you must rebuild. Runtime tier is read when the server starts, so AUTH_REQUIRED can be flipped by restarting with a different value.

Notes & gotchas

Do not cache the HTML. This is the one that will bite you. The shell's first paint is driven by the visitor's own cookies — theme, design skin, OLED mode and layout — so that the design they chose arrives in the very first byte with no flash. Two consequences:

  • A CDN or reverse proxy that caches HTML without including Cookie in the cache key will serve one visitor's design to everybody else. Treat the HTML as private and per-visitor.
  • A proxy that strips cookies before they reach the app breaks the feature completely: every visitor gets the default design and then jumps to their own after hydration, and AUTH_REQUIRED can never see a session. Some CDN configurations do this by default in order to make responses cacheable — check before assuming, because the symptom looks like a slow theme toggle rather than a proxy problem.

/_next/static/** is content-hashed and immutable, so cache it as aggressively as you like. It is only the HTML that must stay uncached.

Routes are dynamic on purpose. Because the shell reads cookies, its routes are marked dynamic in the build output rather than pre-rendered. That is correct for a per-visitor admin surface, not a misconfiguration to fix.

Verify a deployment in two commands. The first proves the server is up; the second proves cookie-driven rendering survived your proxy — the check worth keeping in a smoke test:

curl -sI https://your-domain.com/en | head -1
curl -s -H 'Cookie: theme=dark; design_skin=graphite' https://your-domain.com/en \
  | grep -oE 'data-skin="[^"]*"'

The second must report graphite, and the same response's html tag must carry the dark class. If you get default, something between the browser and the app is dropping cookies.

Node 20 or newer is required. Older runtimes fail at build time.

Was this page helpful?