Self-Hosting Next.js, and What Stops Working
Next.js runs anywhere Node runs. Four features are provided by the platform rather than the framework, and knowing which four is the difference between a calm migration and a surprised one.
"Can we run this on our own infrastructure?" has a short answer and a long
one. The short answer is yes: next start is a Node server, and it runs on a
VM, in a container, behind your load balancer, in your own region.
The long answer is that four things people think of as Next.js features are actually provided by the hosting platform, and they are the four that surprise teams a week after the migration.
The build that is actually deployable
The default build leaves you with a .next directory that needs the whole
node_modules tree beside it. For a container that is hundreds of megabytes
of dependencies you do not run.
// next.config.ts
export default {
output: 'standalone',
};This traces the modules the server actually reaches and copies them into
.next/standalone, with a server.js that boots without a package manager.
The image goes from ~1.2GB to ~180MB in the codebases we have moved.
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 . .
RUN npm run build
FROM node:22-alpine AS run
WORKDIR /app
ENV NODE_ENV=production
# Static assets and public/ are NOT inside standalone. Copying them is the
# step people miss, and the symptom is a site with no CSS.
COPY --from=build /app/.next/standalone ./
COPY --from=build /app/.next/static ./.next/static
COPY --from=build /app/public ./public
EXPOSE 3000
CMD ["node", "server.js"]Two things about that file are worth stating plainly. .next/static and
public/ are not part of the standalone output and must be copied
separately - miss them and the deployment serves HTML with no CSS and no
images. And npm run build runs at image build time, so every environment
variable your code reads at module scope must be present then, not only at
runtime.
The four things the platform was doing
Image optimisation. next/image needs something to resize and re-encode.
On a platform that is a service; on your own server it is sharp, running in
your process, on your CPU.
npm install sharpInstall it and it is used automatically. If your traffic makes that CPU cost
real, point images.loader at a CDN that resizes, or pre-generate sizes at
build time. What you must not do is leave images.unoptimized: true in the
config and forget - that ships the original file to every visitor and quietly
undoes the reason you used the component.
ISR and the revalidation cache. On a single container this works: the
cache is on local disk. On three containers behind a load balancer it does
not - each one has its own disk, revalidateTag clears the cache on the
instance that received the call, and the other two keep serving the old page
until they happen to revalidate. The fix is a shared cache handler:
// next.config.ts
export default {
cacheHandler: require.resolve('./cache-handler.js'),
cacheMaxMemorySize: 0, // disable the in-process LRU; Redis is the source
};Any Redis-backed handler will do. What matters is understanding why it is needed, because "the page updated for some users and not others" is otherwise an impossible-looking bug. Which cache is doing what is worth reading up on: the four layers, and which one bit you.
Middleware at the edge. Your middleware no longer runs in a hundred locations before the request reaches a server; it runs in your server, in your one region. Code written on the assumption that it is nearly free - a geo lookup, an A/B assignment - is now on the critical path of every request. That is a real cost and it is worth measuring rather than assuming.
Streaming through your proxy. Suspense and loading.tsx stream the
response in chunks. An nginx in front with default settings buffers the whole
response before sending it, which turns streaming into a slower version of not
streaming, with no error to tell you.
location / {
proxy_pass http://app:3000;
proxy_buffering off;
proxy_http_version 1.1;
proxy_set_header Connection "";
}What you gain
It is not only cost, though the cost difference at scale is large. You get your data in a jurisdiction you chose, which for a client with a Turkish or EU data-residency requirement is not negotiable. You get to run in the same network as your database, which removes a round trip from every query. And you get a deployment that cannot be changed under you by someone else's pricing decision.
What it costs
Someone now owns uptime. Preview deployments per pull request, instant rollbacks, and a global CDN are real work to rebuild, and a team that has never run infrastructure will spend the first month rediscovering why those features exist.
Our rule of thumb: if the application is one region, has a team that already operates services, or has a data-residency requirement, self-hosting is straightforwardly correct. If it is a marketing site with two engineers and global traffic, the platform is cheaper than the salary of the person maintaining the alternative.
Either way, make the decision on those grounds rather than on a benchmark. The framework runs the same in both places - it is the four features above that move.
