DevOps

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.

By Berke ErdoğanJuly 18, 20268 min read
Self-hosting Next.js in 2026: Docker + Cloudflare production setup

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:

  1. Build the Docker image.
  2. Push to your container registry (GHCR, Docker Hub, or the VPS directly via docker save | ssh docker load).
  3. ssh into the VPS.
  4. 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 run is 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:3000 so 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.

Related caseModitra — B2B Modelist Atelier SaaSModitra · SaaS Platform / UI / UX · 2026Read the case study → Related caseAKS Otomasyon — Industrial Door Services SEO PlatformAKS Otomasyon · Web Design + SEO · 2026Read the case study → Related caseLezzet Mutfağı — Restaurant Brand & Web DesignLezzet Mutfağı · Web Design & Branding · 2025Read the case study →

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.