#Next.js#DevOps

Next.js Deployment Strategies: Vercel vs. Self-Hosting

Vercel is not the only way to deploy Next.js: self-hosting with Docker, Node, or static export, edge deployment, and hybrid approaches have different tradeoffs. This article compares four deployment models, their costs, and the build-vs-run pitfalls of NEXT_PUBLIC environment variables.

Key Points

  • Topic Focus: Next.js、DevOps
  • Suitable for: indie hackers, full-stack engineers, AI application developers
  • Core value: quickly understand background, practical paths, common pitfalls, and engineering advice
Author @admin·Published July 26, 2026·Updated July 26, 2026

"Isn't deploying Next.js just a matter of running the vercel command?" That was the initial assumption of several junior developers I have mentored. Only when a product needs to run in a private cloud, compliance requires self-hosting, or Vercel bandwidth charges suddenly reach five figures does the team seriously investigate how else Next.js can be deployed. That investigation often leads straight into trouble: Why does the NEXT_PUBLIC_ URL remain unchanged when the same image moves to a test environment? Why does the ISR cache alternate unpredictably after deploying multiple instances? Why do Server Actions report "Failed to find Server Action"?

None of these problems is a Next.js bug. They all result from choosing or configuring the wrong deployment model. Next.js 16 makes self-hosting stricter but clearer: output: 'standalone' produces a minimal server.js; multiple instances require a shared encryption key and deploymentId; and Proxy, formerly middleware, depends on the Edge runtime. This article explains four mainstream deployment models by asking "Which features do you use, and which deployment model supports them?" It also provides a Dockerfile you can use directly.

1. Deployment Mode Depends on the Features You Use

Discussing deployment without considering features is meaningless. First determine whether each feature requires a server or can run as a purely static site, and the right choice will usually become clear:

FeatureVercelDocker standaloneSelf-hosted Node (next start)Static export (output: export)
Server Components (dynamic)✅✅✅❌ (build time only)
Route Handlers / API✅✅✅❌
Server Actions✅✅✅❌
ISR (Incremental Static Regeneration)✅ Out of the box⚠️ Cache configuration required⚠️ Cache configuration required❌
Edge / Proxy (formerly middleware)✅✅ (Edge runtime)✅❌
Pure SSG / SPA✅✅✅✅

In short: If you need a Node process at runtime for SSR, ISR, Server Actions, or Route Handlers, you cannot use static export. Conversely, for presentation sites, documentation sites, and admin dashboards that are entirely static, static export with a CDN is the least expensive solution.

2. Vercel Hosting: Zero Configuration at the Cost of Lock-In

Vercel is Next.js's first-class platform; after all, both come from the same company. A git push triggers a deployment, the Edge Network provides global acceleration, and ISR, PPR, Image Optimization, and the Edge runtime work out of the box. Preview environments, traffic shadowing, and instant rollbacks are all available with a click in the console.

The other side of "zero configuration" is platform lock-in and usage-based billing:

  1. Feature lock-in. Outside Vercel, next/after, PPR (Partial Prerendering), and some caching directives may be delayed or unsupported. If your application depends heavily on them, migrating means rewriting code.
  2. Pricing model. Vercel charges for Bandwidth, Edge Function invocations, Image Optimization operations, and Serverless Function execution time. Costs can easily become unmanageable for a consumer site with many images and heavy traffic, which is why many teams are forced to migrate once their DAU grows.
  3. Compliance and data residency. Financial, healthcare, government, and enterprise deployments may require servers in a private data center or designated region, immediately ruling out Vercel's SaaS model.

Suitable for: Rapid launches, full feature support, and teams that do not want to manage operations. Not suitable for: Cost-sensitive high-traffic sites, private-cloud or compliance requirements, and teams that want long-term portability.

3. Docker Standalone: The Mainstream Self-Hosting Option

output: 'standalone' has been the standard self-hosting option since Next.js 12. When enabled, next build creates a minimal runtime in .next/standalone/ with its own server.js and only the required node_modules. You do not need to install all dependencies in the container, so the image can be reduced to a little over 100 MB.

dockerfile
# --- Build stage ---
FROM node:22-alpine AS builder
WORKDIR /app
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && corepack prepare [email protected] --activate \
    && pnpm install --frozen-lockfile
COPY . .

# ⚠️ NEXT_PUBLIC_* is inlined at build time and must be injected with a build arg
ARG NEXT_PUBLIC_SITE_URL=https://example.com
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
RUN pnpm build

# --- Runtime stage ---
FROM node:22-alpine AS runner
ENV NODE_ENV=production
WORKDIR /app
RUN addgroup -S nodejs && adduser -S nextjs
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
USER nextjs
EXPOSE 3000
ENV PORT=3000 HOSTNAME=0.0.0.0
CMD ["node", "server.js"]

Note three details explicitly required by the documentation. First, public and .next/static are not automatically included in standalone, so you must COPY them manually. Second, the official recommendation is to place nginx in front as a reverse proxy to block malicious requests, enforce rate limits, and terminate TLS. Third, pass the port and address to server.js through the PORT / HOSTNAME environment variables; it listens on 0.0.0.0:3000 by default.

The cost of this model is that you must manage the ISR cache, TLS, reverse proxy, logs, and version consistency during rolling deployments yourself, as Sections 6 and 7 explain. One practical technique is to build without access to a real database when the build does not need one. This repository's own Dockerfile sets DATABASE_URL="postgresql://dummy:dummy@localhost:5432/dummy" during the build stage. Because ISR pages can defer rendering until their first production request, the build succeeds without connecting to the database. Decoupling the build from its data sources in this way is essential to creating an environment-independent image.

Suitable for: Teams that need control, lower costs, or private-cloud deployment. It is currently the preferred choice for most self-hosting teams.

4. Static Export: A CDN Is Enough, but Server Capabilities Disappear

output: 'export' makes next build produce an out/ directory containing only HTML, CSS, and JavaScript. Deploy it to Cloudflare Pages, S3+CloudFront, Nginx, or even GitHub Pages for CDN hosting, zero server costs, and global acceleration.

In Next.js 16, Server Components execute during the build for a static export, much like traditional SSG, producing static HTML and a client-navigation payload. You can therefore still write an async Server Component that fetches data during the build, provided it does not use runtime APIs such as cookies or headers.

What you lose is the entire runtime feature set: Route Handlers, Server Actions, ISR, dynamic rewrites, and Proxy are all unavailable. Form submissions, user authentication, and on-demand content updates must use a separate backend. Image Optimization also requires a custom loader or an external service.

Suitable for: Documentation sites, marketing pages, pure SPA admin interfaces, and content that changes infrequently. Not suitable for: Any product that needs runtime server logic. Many developers try to force a dynamic site into a static export, only to add a BFF later to replace the missing capabilities, which defeats the purpose.

Static export also enables a highly effective hybrid architecture: export the Next.js frontend as static assets hosted on a CDN for low cost, global acceleration, and resilience under traffic spikes, while moving all dynamic logic such as login, payments, and data writes into a separate API service. That service can use NestJS, Go, or even another Next.js application running in a container. Each side can evolve and scale independently: changing a frontend style does not require redeploying the backend, and fixing a backend bug does not require rebuilding the frontend. The costs are CORS, authentication propagation, and two separate deployment workflows. It is not worthwhile for a small team with a simple product, but once frontend and backend development move at different speeds, or frontend traffic greatly exceeds backend traffic, this separation can eliminate substantial coordination overhead.

5. Self-Hosted Node (next start): Traditional SSR Flexibility and Overhead

Without enabling standalone, keep the complete node_modules, run next build, and start the service with next start. This was the classic deployment model in the Pages Router era and remains valid. It shares the same foundation as standalone because both run a Node service; the difference is that the image is larger and must contain all dependencies.

This model suits long-running single-server deployments that are insensitive to image size and need locally installed native extensions, such as a custom sharp build or database driver. The cost is managing a complete server: process supervision with pm2/systemd, TLS certificate renewal, log rotation, OOM handling, and security patches. Unless there is a specific reason to use it, I recommend standalone for new projects because installing fewer components leaves fewer opportunities for failure.

6. The Biggest Trap: NEXT_PUBLIC_* Is Inlined at Build Time, Not Runtime

This is the most common deployment mistake I have encountered, bar none. Many developers assume that "environment variable" means a value read at runtime. They pass -e NEXT_PUBLIC_API_URL=... to docker run and expect the container to use the new value, only to find production still calling the staging API.

Here is how it works: NEXT_PUBLIC_* variables are replaced with hard-coded string literals in the JavaScript bundle sent to the browser during next build. Once the build finishes, those values are frozen in the output. Changing environment variables at runtime has no effect because the browser receives an already embedded literal.

The official documentation states this plainly: when promoting one Docker image across multiple environments, every NEXT_PUBLIC_ variable remains frozen at its build-time value. There are only two solutions:

  1. Rebuild for each environment. In CI, use --build-arg NEXT_PUBLIC_API_URL=... to inject the value at build time and produce a separate image for each environment, as shown in this article's Dockerfile.
  2. Switch to runtime injection. Expose client configuration through an /api/config endpoint, read process.env in a Server Component or Route Handler where it remains a runtime value, and return it to the browser. One image can then run in every environment, at the cost of an additional request.

Remember this boundary: Variables without the NEXT_PUBLIC_ prefix are read by the server at runtime, so the image can be reused. Once the prefix is added, the value is frozen at build time, and changing environments requires a rebuild.

7. ISR in Self-Hosted Deployments: Per-Instance Caches Are a Hidden Time Bomb

By default, the ISR cache is stored on the local disk of each Next.js instance. Everything works normally with one long-running instance. Once you scale horizontally to multiple containers, such as multiple K8s Pods or load-balanced instances, each instance has its own independent cache. A revalidateTag / revalidatePath call invalidates only the current instance, while other instances continue serving stale data. Every refresh can make the content alternate between "new" and "old."

The official Next.js 16 solution has three parts:

  1. A custom cacheHandler. Replace the local-disk cache backend with shared storage such as Redis or S3, and set cacheMaxMemorySize: 0 to disable the in-memory cache.
  2. A shared encryption key. Every instance must use the same NEXT_SERVER_ACTIONS_ENCRYPTION_KEY, a base64-encoded 16/24/32-byte AES key. Otherwise, Server Actions cannot be decrypted across instances and report "Failed to find Server Action."
  3. A deploymentId to prevent version mismatches. During a rolling deployment, old and new instances coexist, and clients may request static assets that have already been removed. Configure deploymentId: process.env.DEPLOYMENT_VERSION so clients built against the new version automatically perform a full-page refresh instead of failing during soft navigation.

In other words, self-hosting multiple instances is not simply a matter of starting more copies; it is a distributed system that requires explicit coordination. Stay with one instance while it can handle the load. When scaling becomes necessary, a shared Redis cache is not optional.

Summary

Deployment selection begins by answering two questions: Which runtime features do you use, and does your environment permit platform lock-in?

  • Features determine the model. SSR, ISR, or Server Actions require a Node process, immediately ruling out static export.
  • Vercel suits rapid launches and full feature support, but Bandwidth and Function charges grow with scale, while deep dependence on PPR or Edge features increases platform lock-in.
  • Docker standalone is the mainstream self-hosting option. Its image is small and includes server.js, but you must manage the reverse proxy, TLS, and cache coordination yourself.
  • Static export is the least expensive option. CDN hosting requires no server, at the cost of losing every runtime server capability.
  • NEXT_PUBLIC_* is frozen at build time, not runtime. Reusing an image across environments without rebuilding is a classic cause of production calling staging.
  • Multi-instance self-hosting requires a shared cache and encryption key. Otherwise, ISR alternates between fresh and stale content, and Server Actions fail across instances.

The key takeaway: A deployment model is not about choosing a tool; it is about accepting an operational cost you can sustain for your feature set and the boundaries you need to control. Vercel trades money for convenience, while self-hosting trades operational effort for control. There is no free lunch, only a bill whose reckoning has been postponed.

Related

Related Content