Self-hosting Next.js in 2026: Docker + Cloudflare production setup
Vercel is the easy answer. It's also the expensive one once traffic scales. Here is the self-hosted Next.js stack we ship for client work — Docker standalone output, Cloudflare in front, ~$25/month all-in for a mid-scale B2B SaaS.

TL;DRVercel's ergonomics are excellent; its pricing bites once you cross a certain traffic threshold. Self-hosted Next.js 16 on Docker + Cloudflare gets you ~90% of the Vercel experience at ~10% of the cost. This is the exact stack we ship — Dockerfile, next.config, Cloudflare rules, cache strategy — with the failure modes we've paid for.
The default Next.js deployment story is Vercel. Its ergonomics are
excellent — git push and you're live. Its pricing is also excellent
until you have real traffic.
This is the self-hosted alternative we ship for client work in 2026: Next.js 16 standalone output → Docker → Natro VPS or a DigitalOcean droplet → Cloudflare in front. Total monthly bill for a mid-scale B2B SaaS: ~$25.
When self-hosting is worth it
Self-host when:
- Traffic is measured in the hundreds-of-thousands of visits per month.
- Bandwidth or serverless-invocation costs on Vercel are already north of $100/month.
- You want to run auxiliary services (Postgres, Redis, workers) alongside the app without paying per-integration.
- Data residency requires Türkiye, EU, or other specific regions Vercel doesn't guarantee.
Stay on Vercel when:
- Traffic is small or bursty and you don't want to think about ops.
- The team has no one on-call for infrastructure.
- You need Vercel-specific features (Preview Deployments, Image Optimization at their edge scale, KV / Blob storage).
There is a middle ground — use Vercel until the bill exceeds the ops cost of self-hosting. That crossover is typically around 300k monthly visits for a stateful B2B SaaS.
The Dockerfile
Multi-stage build. The standalone output is the whole game — it
bundles only the code the app actually uses, dropping ~200 MB off the
image.
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm ci --omit=dev
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NEXT_TELEMETRY_DISABLED=1
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
RUN addgroup --system --gid 1001 nodejs
RUN adduser --system --uid 1001 nextjs
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
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
CMD ["node", "server.js"]
Final image size: 180–220 MB.
Critical: output: "standalone" must be set in next.config.ts.
Without it, .next/standalone won't exist and the copy in the runner
stage silently produces a broken image.
next.config.ts — the non-obvious knobs
const nextConfig: NextConfig = {
output: "standalone",
compress: true,
poweredByHeader: false,
images: {
formats: ["image/avif", "image/webp"],
minimumCacheTTL: 31536000, // 1 year — long-lived variants
},
async headers() {
// Security + cache headers set at the app layer.
// Cloudflare respects s-maxage for edge caching.
return [
/* ... HSTS preload, X-Content-Type-Options, CSP, etc. ... */
{
source: "/en/:path*",
headers: [{
key: "Cache-Control",
value: "public, max-age=0, s-maxage=3600, stale-while-revalidate=86400",
}],
},
];
},
};
The s-maxage vs max-age split matters. max-age=0 tells the
browser not to cache; s-maxage=3600 tells shared caches (Cloudflare)
to cache for an hour. Together: fresh content for the user, edge cache
for volume.
stale-while-revalidate=86400 lets Cloudflare serve slightly stale
content for a day while a background refresh updates it. Origin never
sees a spike.
The Cloudflare setup
DNS: A record → your droplet IP, orange-clouded (proxied).
SSL / TLS: Full (strict). Origin certificate installed on the VPS. Don't use Flexible — it decrypts between Cloudflare and your origin, which invalidates the security posture.
Cache Rules:
- HTML pages: honor origin cache-control (already set correctly by Next.js).
- Static assets (
/_next/static/*): cache everything, browser TTL a year, edge TTL a month. - API routes (
/api/*): bypass cache.
Page Rules to remove: legacy "Always Online" (breaks streaming), "Rocket Loader" (breaks React), "Auto Minify" (breaks Next.js chunk integrity).
Security: WAF at OWASP baseline. HSTS preload requires enabling
"Preload" toggle in Cloudflare SSL/TLS settings, plus the
Strict-Transport-Security header at origin.
Deploy pipeline
Simple GitHub Actions:
- Build the Docker image.
- Push to your container registry (GHCR, Docker Hub, or the VPS
directly via
docker save | ssh docker load). sshinto the VPS.docker pull+docker stop+docker rm+docker run.
Zero-downtime is possible with a reverse proxy (nginx, Caddy, Traefik)
in front, but for most projects the 2–5 second gap during docker rm
docker runis inside acceptable SLA. If you truly need zero-downtime, run two containers on different ports and swap the nginx upstream.
The failure modes we've paid for
output: "standalone"missing: Docker build "succeeds" but the runner stage produces an image that starts and immediately exits.- Port collision on shared VPS: multiple Docker containers
competing for the same host port. Fix:
--publish 127.0.0.1:PORT:3000so nginx routes based on hostname. - Environment variables not passed to build:
NEXT_PUBLIC_*vars are baked in at build time, not runtime. Missing them at build = broken client-side vars in production. Pass them as--build-arg. - Cloudflare + streaming: Cloudflare buffered response bodies until fairly recently, which broke Next.js streaming. Modern Cloudflare handles it, but if you see full-page waits, check "Enable Response Buffering" is OFF.
Monthly cost breakdown
- VPS (Natro, 2 vCPU / 4 GB RAM): ~$12/month
- Cloudflare (Free tier): $0
- SSL: $0 (Let's Encrypt origin + CF managed edge)
- Container registry (GHCR free tier): $0
- Postgres (VPS-hosted, same box): $0 extra
- Redis (VPS-hosted): $0 extra
- Backups (Cloudflare R2 offsite): ~$5/month for 50 GB
- Monitoring (Uptime Kuma self-hosted): $0
Total: ~$17–25/month all-in.
Compare to Vercel Pro ($20/user/month) + bandwidth + serverless invocations + KV / Blob storage: typically $80–200/month at the same traffic level.
When self-hosting stops being worth it
The moment you need multi-region active-active, real-time serverless scale-to-zero for spiky workloads, or fully managed edge functions outside HTTP request/response — Vercel or a similar platform wins.
Until then, self-hosted Next.js is one of the higher-leverage architectural decisions a small team can make.
If you're on Vercel and the bill is climbing, or you're greenfield and want the self-hosted stack from day one, contact us — we ship this stack in every client project and can move an existing site over in a few days.