Modern Web · Nextjs

Next.js 15 Deployment to Production

Deploying Next.js 15 outside Vercel: standalone output for Docker, choosing edge vs Node runtime per route, environment variables at build vs runtime, and running ISR on your own infrastructure.

John Kihiu12 min read

Vercel built Next.js, and it shows — the fastest path to shipping any Next.js 15 app is still vercel deploy, with zero-config ISR, edge middleware, and image optimization handled for you. But plenty of teams have infrastructure requirements Vercel doesn't fit: data residency rules, an existing Kubernetes cluster, or just a cost model that doesn't work at Vercel's per-request pricing once traffic scales. Self-hosting Next.js 15 is well-supported now, but it means owning decisions Vercel used to make invisibly.

Standalone output for Docker

Setting output: 'standalone' in next.config.js makes the build produce a minimal, self-contained server — Next.js traces exactly which node_modules files your app actually uses and copies only those into .next/standalone, instead of you needing to ship the entire dependency tree. This is the difference between a multi-hundred-megabyte image and a lean one, and it's what makes Next.js practical to run in Docker without a bespoke pruning script.

DOCKERFILE · MULTI-STAGE BUILD WITH STANDALONE OUTPUT
FROM node:20-alpine AS builder
WORKDIR /app
COPY package*.json ./
RUN npm ci
COPY . .
RUN npm run build

FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]
Standalone output doesn't include public/ or .next/static automatically

The traced output only covers server code and its dependencies — you still have to copy the public/ directory and .next/static into the final image yourself, as shown above. Skipping this is the most common "why are my images and CSS 404ing in production" mistake with standalone builds.

Edge vs. Node runtime, chosen per route

Self-hosted or on Vercel, Next.js 15 lets you set export const runtime = 'edge' or 'nodejs' per route or middleware file. The Node runtime gives you the full Node.js API surface — native modules, Buffer, arbitrary npm packages that assume Node internals — at the cost of a heavier, slower-to-cold-start process. The Edge runtime is a stripped-down, Web-standard-APIs-only environment that starts faster and runs closer to the request, but can't use anything requiring Node-specific APIs. If you're self-hosting behind a single region, the edge runtime's latency advantage mostly disappears — it's a bigger win with Vercel's or another provider's global edge network than on a single VPS.

Environment variables: build time vs. runtime

This is where self-hosted deployments most often go wrong. Any variable prefixed NEXT_PUBLIC_ gets inlined into the client JavaScript bundle at build time — it is baked in, not read fresh at runtime. If you build one Docker image and expect to promote it through staging and production with different NEXT_PUBLIC_API_URL values via runtime environment injection, it won't work; the value from whichever environment ran the build is permanent in that image. Server-only variables (no NEXT_PUBLIC_ prefix) are read at runtime and can differ per environment for the same image.

BASH · BUILD-TIME VS RUNTIME ENV DISTINCTION
# WRONG for multi-environment images: baked in at build, can't change later
# NEXT_PUBLIC_API_URL=https://staging.api.example.com  npm run build

# Build once, generic:
npm run build

# Server-side vars can differ per environment at container start,
# because server code reads process.env at request time, not build time
docker run -e API_SECRET=prod_secret -e DATABASE_URL=$PROD_DB my-nextjs-app

ISR on your own infrastructure

Incremental Static Regeneration works self-hosted, but Vercel's version relies on their global CDN and a managed cache invalidation layer that a plain Docker container doesn't have. Self-hosted, revalidated pages are written to disk on the server instance itself by default — which breaks down the moment you run more than one container instance, because each instance regenerates and caches independently and users can get different cached versions depending on which instance serves them. Running ISR at scale self-hosted means putting a shared cache (Redis, or a CDN in front of the app with its own invalidation hooked to revalidatePath/revalidateTag) in the path, not relying on the filesystem cache that works fine for a single instance.

Health checks need a route that doesn't hit your cache logic

If your load balancer's health check hits a page that triggers ISR regeneration, you can end up regenerating on every health check interval, defeating the point of the cache. Use a dedicated lightweight route (e.g. /api/health returning a static 200) for health checks, separate from user-facing cached pages.

Wrapping up

Self-hosting Next.js 15 is a legitimate choice, not a downgrade — standalone output makes the Docker story clean, and edge/Node runtime selection is available regardless of host. The two things that actually bite teams are environment variables baked in at build time when they expected runtime configuration, and ISR silently misbehaving across multiple instances because the default cache is per-instance disk, not shared. Plan for both before you're debugging them in production.

John Kihiu
Acumatica ERP Developer · Laravel Engineer

Independent software engineer in Nairobi specialising in Acumatica customisations, Laravel backends, and tax fiscalisation integrations across East and Southern Africa.