Next.js runs anywhere Node.js runs. The framework doesn't require a specific host — what a managed platform provides is a set of optimized defaults for caching, routing, and scaling that you'd otherwise assemble yourself.
Understanding what those defaults actually do is the prerequisite for replacing them. This guide covers the architecture of a self-hosted Next.js deployment, the configuration that matters, and an honest account of what you take on when you move off a managed platform.
TL;DR: Self-hosting Next.js means running the standalone build in a container, putting a reverse proxy in front for TLS and caching, and automating deployment from your repository. You gain cost predictability, data residency, and infrastructure control. You take on uptime, scaling, patching, and caching strategy — the parts a managed platform was doing quietly. There's a middle path between both extremes that most teams should consider first.
Three Hosting Models, Not Two
The framing of "Vercel or self-host" skips the option most teams actually want.
Managed platform-as-a-service | Managed container platform | Raw VPS | |
|---|---|---|---|
You provide | Repository | Container or repository | Everything |
You operate | Nothing | Your application | OS, proxy, TLS, Docker, monitoring |
Caching and CDN | Built in | Usually configurable | You build it |
Scaling | Automatic | Configured | Manual or scripted |
Framework coupling | High | Low | None |
Ops burden | None | Low | Substantial |
The middle column is where most teams land once they've tried both ends. You keep Git-driven deployment, health checks, scaling, and rollback, but you're not locked into framework-specific platform behavior, and you're not personally running certbot on a Tuesday.
Raw VPS makes sense when you have data residency requirements, unusual infrastructure needs, existing operational capacity, or genuinely predictable traffic where fixed capacity beats elastic pricing.
The rest of this guide covers the self-hosted architecture in detail, because understanding it is useful regardless of which column you choose — a managed container platform runs the same container you'd run yourself.
Understanding What You're Replacing
A managed Next.js platform handles four things silently. Self-hosting means handling each one deliberately.
Static asset delivery. Hashed build assets are immutable and should be served with long cache headers from a fast path — ideally from disk or a CDN edge, not through your Node process.
Server-rendered and dynamic routes. These need a long-running Node process. Server Components, route handlers, middleware, and server actions all require it.
Incremental Static Regeneration. Pages regenerate on a schedule or on demand. Self-hosted, regenerated pages are written to the container's filesystem — which has implications for multi-instance deployments covered below.
Edge and CDN caching. Absorbs the majority of requests before they reach origin.
Decide your runtime mode first. If your app uses route handlers, server actions, ISR, or middleware, you need a Node.js server. Static export only works for fully static sites, and discovering that mid-migration is a bad time to find out.
Step 1: Configure Standalone Output
Next.js can produce a standalone build that traces your imports and copies only the files needed at runtime, plus a minimal server entry point.
js
/** @type {import('next').NextConfig} /
const nextConfig = {
output: 'standalone',
poweredByHeader: false,
};module.exports = nextConfig;
This is the single highest-impact change before containerizing. Without it, your image carries the entire dependency tree including build-time packages. With it, you ship what actually runs. The size difference is substantial — measure yours, since it depends entirely on your dependency tree.
Smaller images pull faster, which directly shortens deployment and scale-up time.
On compression: enable it in the framework only if nothing in front is already compressing. If you're running a reverse proxy or CDN that handles it, doing it in both places wastes CPU on every request for no benefit.
Disable the framework header. It tells attackers which framework and sometimes which version you're running, for zero benefit to you.
Step 2: Build a Production Container
A multi-stage build keeps build tooling out of the runtime image.
dockerfile
# Build stage
FROM node:<pinned-version>-alpine AS builder
WORKDIR /app
COPY package.json ./
RUN npm ci
COPY . .
RUN npm run buildRuntime stage
FROM node:<pinned-version>-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
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"]
Three things this gets right:
Non-root execution. A dedicated user limits blast radius if the application is compromised, and it's a standard requirement in security review. Many tutorials skip it.
Multi-stage separation. Build dependencies never reach the runtime image.
Correct standalone layout. The public and .next/static directories must be copied separately — standalone tracing doesn't include them, and missing this produces an app that runs but serves no assets or images. It's a common and confusing first failure.
Pin your Node version, ideally by digest rather than tag. A tag can point to different content over time, which produces build failures that appear without any change on your side. Check current Node LTS and Next.js compatibility rather than copying a version from any guide — this is the fastest-aging part of any deployment tutorial.
Build locally and run it before pushing anywhere. A failure on your machine costs two minutes; the same failure in a pipeline costs a build cycle.
If you're deploying to a platform, deploy from a Dockerfile uses the same image you tested locally.
Step 3: Reverse Proxy Configuration
A reverse proxy handles TLS termination, compression, and caching without touching application code.
nginx
server {
listen 443 ssl;
http2 on;
server_name app.example.com;ssl_certificate /path/to/fullchain.pem;
ssl_certificate_key /path/to/privkey.pem;
Serve hashed build assets directly from disk
location /_next/static/ {
alias /srv/app/.next/static/;
access_log off;
expires 1y;
add_header Cache-Control "public, max-age=31536000, immutable";
}
location / {
proxy_pass http://127.0.0.1:3000;
proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection $connection_upgrade;
proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme;
}
}
Serve static assets from disk, not through Node. Proxying /next/static/ to the Node process defeats the purpose — every asset request then occupies a Node event loop slot that should be serving dynamic requests. Mount the build output where the proxy can read it directly. This is the difference between a reverse proxy that helps and one that just adds a hop.
Hashed assets are safe to cache for a year. Filenames change on every build, so stale content isn't possible.
Use a mapped variable for the Connection header, not a hardcoded upgrade. Setting it unconditionally sends an upgrade header on every request, including ones that aren't WebSocket handshakes. Map it from $http_upgrade instead.
Consider a micro-cache for ISR pages if you serve significant traffic. Caching regenerated pages for a short interval absorbs spikes that would otherwise all reach origin. How much it helps depends entirely on your traffic pattern and page mix — measure before and after.
Never cache authenticated or personalized responses. Caching a response that varies by user leaks one user's data to another. Restrict caching to genuinely public routes with no user context. This is the most damaging mistake available in proxy configuration.
Step 4: Automate Deployment
Manual SSH deploys don't survive contact with a growing team. Every deployment should be traceable to a commit.
The pattern: build on merge, push the image to a registry, and roll the running container over to the new image.
The naive version stops the old container and starts the new one, which means downtime for the duration of startup. It's fine for internal tools and wrong for anything customer-facing.
Zero-downtime needs an overlap window:
Start the new container on a different port
Health-check it until it reports ready
Switch the proxy upstream to the new port
Drain connections from the old container
Stop the old container
That sequence is the core of blue-green deployment, and doing it by hand in a shell script is where self-hosting starts consuming real engineering time. It's also the point where a managed platform earns its cost for most teams — Git-based deployment with auto-deploy does this without a script to maintain.
Keep secrets out of the image. Anything in a build layer is readable by anyone who can pull it. Pass configuration at runtime through environment variables, so the same tested image runs in every environment with only configuration differing.
Make rollback fast. Immutable image tags mean reverting is redeploying a previous tag rather than rebuilding. Logs and rollbacks preserve the failed deploy's output while you restore service.
Step 5: Health Checks, Monitoring, and Backups
Health checks
Add a route that returns success only when the application can actually serve — including reachability of the database and any cache it depends on.
Distinguish liveness from readiness. Liveness means the process is running. Readiness means it can handle a request. If your health check only confirms the process started, traffic routes to a container that hasn't finished initializing, and requests fail for reasons that look mysterious.
Monitoring
Layer | Track | Catches |
|---|---|---|
Container | CPU, memory, restarts | Leaks, OOM kills |
Application | Latency p95/p99, error rate by route | Degradation, broken endpoints |
Data layer | Connection pool, query latency | The usual real bottleneck |
Business | Signups, conversions, core actions | Failures metrics miss |
Alert on memory trend, not just threshold. A Node process with a slow leak crosses the line at an unhelpful hour. Rising memory over hours is the signal worth catching.
Alert on p95 and p99, not averages. Averages hide the tail, and the tail is the user who left.
Wire container metrics and application metrics through metric ingestion so both appear in one view.
Backups
Snapshot your database on a schedule and test a restore periodically. An untested backup is a hypothesis, and the first test usually surfaces something.
Self-hosting means you own this. There's no one else checking.
Multi-Instance Considerations
Scaling past one instance introduces problems that don't exist on a single server, and they surprise people.
ISR cache is per-instance by default. Regenerated pages are written to the local filesystem, so instance A regenerating a page doesn't help instance B. Users get inconsistent content depending on routing. Solutions include a shared cache handler backed by external storage, or accepting the inconsistency if your revalidation interval is short enough that it doesn't matter.
Sessions must be external. Any in-memory session state breaks the moment a user is routed to a different instance. Use a shared store.
Uploads must go to object storage, never the local filesystem. A file written to one instance doesn't exist on the others, and it disappears entirely when that instance is replaced.
In-memory caches are per-instance. Application-level caching that assumes a single process produces inconsistent behavior across instances. Either make it external or accept per-instance variance deliberately.
The general rule: anything written at runtime that another instance needs must live outside the container. Scaling presets handle instance count; statelessness is your responsibility regardless of platform.
Weighing the Trade-off
Self-hosting tends to win when:
Traffic is steady and predictable, so fixed capacity beats per-request pricing
Data residency or compliance requires specific infrastructure
You need infrastructure a managed platform doesn't offer
You already have operational capacity and monitoring
Managed hosting tends to win when:
Traffic is spiky or unpredictable
Your team is small and engineering time is the constraint
Global distribution matters and you'd otherwise build a CDN layer
Nobody wants to be on call for certificate renewal
Be honest about the full cost. Self-hosting consumes engineering time for setup, monitoring, patching, incident response, and the blue-green deploy script somebody has to maintain. Whether that's cheaper depends on what your engineering hours cost and whether you have them spare.
The cost comparison people skip is on-call. A managed platform's uptime is someone else's problem at 3am. That has a value, and it isn't zero.
A reasonable path: start managed, measure real traffic and cost, and move only if the numbers justify it. Optimizing infrastructure cost before you have traffic is the most common premature optimization in web development. Cloud deployment vs. traditional hosting covers the broader comparison.
Common Mistakes
Skipping standalone output. The image carries the full dependency tree, deploys slow down, and scale-up time suffers.
Running as root. Fails security review and expands blast radius. Create a dedicated user.
Forgetting to copy public and .next/static. The app runs but serves no assets. Confusing first failure, easy fix.
Proxying static assets through Node. Every asset request occupies an event loop slot that should serve dynamic requests. Serve from disk.
Caching authenticated responses. Leaks one user's data to another. The most damaging proxy misconfiguration available.
No readiness check. Traffic routes to containers that haven't finished starting.
Stop-then-start deploys on customer-facing apps. Guaranteed downtime every release. Overlap the containers.
Assuming multi-instance works like single-instance. ISR cache, sessions, uploads, and in-memory state all need external storage once you scale past one.
Untested backups. Not backups.
Frequently Asked Questions
Can I host Next.js without Vercel?
Yes. Next.js is open source and runs anywhere Node.js runs — a container on any Linux host, a managed container platform, or your own orchestration. What a managed platform provides is optimized defaults for caching, routing, and scaling. Self-hosting means configuring those yourself, which is straightforward for a single instance and more involved once you scale.
What is standalone output and why does it matter?
It's a build mode that traces your application's imports and copies only what's needed at runtime, plus a minimal server entry point. It matters because without it your container image includes the full dependency tree, including build-time packages. Smaller images pull faster, which shortens deployment and scale-up time directly.
How do I get zero-downtime deployments?
Overlap the old and new containers. Start the new one on a different port, health-check it until ready, switch the proxy upstream, drain connections from the old one, then stop it. Stopping before starting guarantees downtime for the length of startup — which for a Next.js app is short but not zero, and it happens on every release.
Do I need a CDN when self-hosting?
Not strictly. A reverse proxy serving static assets from disk with long cache headers handles most asset load for a single-region audience. A CDN matters when users are geographically distributed, since it removes the round trip to your origin region entirely. Start without one and add it when latency data shows you need it.
How do I handle environment variables securely?
Never bake secrets into the image — build layers are readable by anyone who can pull it. Pass them at runtime through your platform's environment configuration or secret mechanism. Keep them out of the repository entirely, and rotate them on a schedule. Note that Next.js inlines NEXT_PUBLIC variables at build time, so anything prefixed that way is shipped to the browser.
Can I run a production Next.js app on a single small server?
For low to moderate traffic, yes — particularly with a reverse proxy handling static assets and caching. Scale vertically first; it's simpler than managing multiple instances. The moment you add a second instance you inherit ISR cache consistency, session storage, and upload handling as new problems, so delay that step until traffic requires it.
What breaks when I scale to multiple instances?
Anything written at runtime that another instance needs. ISR-regenerated pages land on one instance's filesystem. In-memory sessions don't survive routing to a different instance. Uploaded files exist on one container and vanish when it's replaced. Each needs external storage — a shared cache backend, a session store, and object storage respectively.
Is self-hosting actually cheaper?
At steady traffic, infrastructure cost is usually lower — you're paying for fixed capacity rather than per request. Whether it's cheaper overall depends on engineering time for setup, monitoring, patching, and incident response, plus the value of not being on call. Measure your real traffic on a managed platform first, then compare against a realistic self-hosted estimate including labour.
Getting Started
The architecture is consistent regardless of where you run it: standalone build in a multi-stage container running as a non-root user, a reverse proxy serving static assets from disk and forwarding dynamic requests, deployment automated from your repository with fast rollback, and readiness checks gating traffic.
What changes between hosting models is how much of that you operate yourself. A managed container platform runs the same image you'd run on your own server, which means the containerization work above isn't wasted if you change your mind later.
Start with whichever model matches your current operational capacity, measure real traffic, and move when the data justifies it — not before.