01 · Deployment: Managed & Self-Hosted¶
A Next.js app can run anywhere Node.js runs. Managed platforms make it easy; your own server or container gives you control and predictable costs. Understanding the self-hosted path also makes you a better user of managed platforms, because you know what they're doing for you.
Five deployment shapes¶
| Shape | What you deploy | Supports |
|---|---|---|
Node server (next build + next start) |
The project + node_modules |
Everything |
| Standalone output | .next/standalone folder (minimal files) |
Everything |
| Docker container | An image built from standalone output | Everything |
Static export (output: "export") |
Plain HTML/CSS/JS files | No server features (lesson 08) |
| Managed platform | Your repo; the platform builds it | Everything, plus platform extras |
next start on a server¶
The simplest production deployment:
Run it under a process manager (systemd, PM2, a container orchestrator) that restarts it on crash and on boot. Put a reverse proxy (nginx, Caddy) in front for TLS, compression and static caching if you like — but configure it not to buffer responses, or streaming stops working (Level 3 · 02):
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Host $host;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
proxy_buffering off; # let streamed chunks through immediately
}
Standalone output¶
Deploying all of node_modules is wasteful. With standalone output, the build traces
which files the server actually imports and copies only those:
import type { NextConfig } from "next";
const nextConfig: NextConfig = { output: "standalone" };
export default nextConfig;
On the Level 2 project (with Drizzle, SQLite, Zod and the dev tooling installed), we observed:
$ ls .next/standalone
node_modules package.json server.js
$ du -sh .next/standalone node_modules
49M .next/standalone
630M node_modules
The standalone folder doesn't include public/ or .next/static/ (they're meant for
a CDN); copy them in if the Node server should serve them:
cp -R public .next/standalone/
cp -R .next/static .next/standalone/.next/
cd .next/standalone && PORT=3122 node server.js
We ran exactly that, and the server started (▲ Next.js 16.3.6 … Local: http://localhost:3122)
and served /blog with status 200.
Docker¶
A multi-stage Dockerfile building standalone output (we could not run Docker on the machine used to write this lesson, so no build output is shown; the structure follows the official example's approach):
# 1. Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
# 2. Build
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
# 3. Run: only the standalone output
FROM node:22-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production NEXT_TELEMETRY_DISABLED=1 PORT=3000 HOSTNAME=0.0.0.0
RUN addgroup -S nodejs && adduser -S nextjs -G nodejs
COPY --from=builder /app/public ./public
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]
Notes:
- Native modules (like
better-sqlite3) must be compiled for the runtime image's platform — building on macOS and copyingnode_modulesinto a Linux image fails. Installing inside the image, as above, handles it; Alpine uses musl, so some native packages prefer a Debian-basednode:22-slimimage. HOSTNAME=0.0.0.0makes the server listen on all interfaces inside the container.- Environment variables used by server code are read at runtime;
NEXT_PUBLIC_variables are baked in at build time, so they must be present duringdocker build.
Running several instances¶
Behind a load balancer with more than one instance, a few things must be shared or consistent:
- Cache: by default each instance has its own in-memory/filesystem cache, so
revalidating on one doesn't update others. Configure a shared cache handler (for
example backed by Redis) via
cacheHandlers/cacheHandlerconfig. - Server Action encryption key: set
NEXT_SERVER_ACTIONS_ENCRYPTION_KEYso all instances can decrypt each other's action payloads. - Build ID / deployment ID: all instances must run the same build; set
deploymentIdto help clients detect version skew during rolling deploys. - Image optimisation cache: per instance unless you put a CDN in front.
Managed platforms¶
Vercel (the company behind Next.js), Netlify, Cloudflare, AWS Amplify and others build from your repository and handle scaling, CDN distribution of static assets, preview deployments per branch, and platform-specific features (edge caching of static shells, image optimisation services, cron triggers). Next.js 16 introduced a Build Adapters API (alpha at the time of writing) meant to let any platform integrate with the build output in a supported way.
How to choose:
| If you value… | Consider |
|---|---|
| Zero ops, previews, global CDN out of the box | A managed platform |
| Predictable cost at steady high traffic, data residency, existing infra | Self-hosted Node / containers |
| No server at all | Static export |
Check any platform's documentation for which Next.js features it supports and how (streaming, PPR, image optimisation, ISR) — support differs and changes.
How It Actually Works¶
next build produces .next/: compiled server bundles per route, client chunks in
.next/static/, prerendered HTML/RSC files, and manifests. With output: "standalone",
the build also runs file tracing (@vercel/nft): starting from each server entry
point, it follows every require/import and file read it can detect, and copies only
those files — including the specific parts of node_modules actually used — into
.next/standalone, along with a minimal server.js that boots the Next.js server
without the CLI. That's how 630 MB of dependencies became 49 MB. Anything loaded
dynamically in a way tracing can't see (a computed path) must be included with
outputFileTracingIncludes.
Common mistakes¶
- Reverse proxy buffering that breaks streaming.
- Forgetting to copy
public/and.next/static/into standalone deployments — pages load without CSS. - Expecting
NEXT_PUBLIC_values to change at runtime. - Multiple instances with separate caches and no shared handler — users see different data depending on which instance they hit.
- Running
next devin production.
Exercise¶
- Build your Level 2 project with
output: "standalone", compare folder sizes, copy the static assets and runnode server.js. - If you have Docker, build the image and run it with a volume for the SQLite file. Record the image size.
- Put Caddy or nginx in front, and use
curl -Nagainst the Level 3 dashboard to confirm streaming still works through the proxy.