How to Deploy a Next.js Application on Kubernetes: A Practical Guide

How to Deploy a Next.js Application on Kubernetes: A Practical Guide

Next.js Kubernetes Docker DevOps

I moved my first Next.js app to Kubernetes because Vercel's bill for a client project quietly crossed four figures a month — mostly from image optimization and edge function invocations we didn't fully control. Kubernetes wasn't cheaper on day one. It got cheaper once we understood our own traffic patterns and stopped paying for someone else's abstraction. That's really the honest reason teams end up here: not because Kubernetes is trendy, but because at some point you want the knobs yourself.

How to Deploy a Next.js Application on Kubernetes

This guide walks through actually shipping a Next.js app on a Kubernetes cluster — building the right kind of Docker image, writing manifests that won't bite you at 2 a.m., handling environment variables properly, and scaling without guessing. No hand-waving, no "it depends" without an explanation of what it depends on.

Who this is for: developers who know Next.js and have touched Docker, but haven't run it inside a Kubernetes cluster yet. If you're brand new to containers, read the Docker section slowly — everything after it assumes you're comfortable with images and containers.

Why bother with Kubernetes for Next.js at all

Platforms like Vercel and Netlify exist precisely so you don't have to do any of this. For a marketing site or a small SaaS with predictable traffic, they're the right call — genuinely, I still reach for Vercel first for anything under a certain scale. Kubernetes earns its complexity when one or more of these is true:

  • Your infra already lives in Kubernetes — internal APIs, databases, message queues — and you want the Next.js app to sit in the same network, sharing service discovery and secrets.
  • Compliance requires you to control exactly where the app runs, down to the region or the physical cluster.
  • You need autoscaling behavior that's tuned to your own metrics, not a platform's black-box heuristics.
  • Cost at scale genuinely favors owning the compute, especially with predictable, high, sustained traffic rather than spiky serverless-friendly traffic.

If none of those apply, it's fine to stop reading and go deploy on a managed platform. Seriously. But if you're here, let's build this properly.

Step 1: Containerize the Next.js app the right way

The single biggest mistake I see is people copying a generic Node.js Dockerfile and wondering why their image is 1.2GB. Next.js has a built-in output: 'standalone' mode specifically designed for containers, and skipping it is the most common reason deployments end up bloated and slow to start.

First, in next.config.js:

/** @type {import('next').NextConfig} */
const nextConfig = {
  output: 'standalone',
  reactStrictMode: true,
};

module.exports = nextConfig;

This tells Next.js to trace exactly which files and node_modules your app actually needs at runtime and copy only those into a minimal output folder. It's a genuinely clever piece of engineering — the difference in my case was a 1.1GB image down to roughly 180MB.

Now the Dockerfile, using a multi-stage build so build tools never make it into the final image:

# --- Stage 1: install dependencies ---
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci

# --- Stage 2: build the app ---
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
RUN npm run build

# --- Stage 3: production runtime ---
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production

RUN addgroup --system --gid 1001 nodejs && \
    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

CMD ["node", "server.js"]
Why the non-root user matters: Kubernetes clusters with Pod Security Standards enforced (which most production clusters should have) will reject containers running as root outright. Building this in now saves you a confusing failure later.

Build and tag it, then push to whatever registry your cluster can pull from — Docker Hub, GHCR, ECR, GCR, it doesn't matter which:

docker build -t yourregistry/nextjs-app:1.0.0 .
docker push yourregistry/nextjs-app:1.0.0

Avoid tagging with latest in anything resembling production. I've debugged a "the deployment didn't update" incident that turned out to be a stale cached latest image on a node that hadn't pulled fresh. Semantic version tags or git SHAs cost nothing and save you that afternoon.

Step 2: Writing the Kubernetes manifests

You need, at minimum, a Deployment, a Service, and — if you want the app reachable from outside the cluster — an Ingress. Let's go through them one at a time rather than dumping 150 lines of YAML at once.

The Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: nextjs-app
  labels:
    app: nextjs-app
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nextjs-app
  template:
    metadata:
      labels:
        app: nextjs-app
    spec:
      containers:
        - name: nextjs-app
          image: yourregistry/nextjs-app:1.0.0
          ports:
            - containerPort: 3000
          env:
            - name: NODE_ENV
              value: "production"
            - name: API_URL
              valueFrom:
                configMapKeyRef:
                  name: nextjs-config
                  key: api_url
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 15
            periodSeconds: 20

Three replicas as a starting point, not a rule. What actually matters here are the resource requests and the probes — both of which people skip when they're in a hurry, and both of which cause real pain later.

Common mistake: Deploying without resource requests/limits. Kubernetes' scheduler makes bin-packing decisions based on requests. Skip them, and you'll eventually get a node starved by one noisy pod while others sit idle — I've seen a single misbehaving deployment take down three unrelated services on the same node because nobody set limits.

You'll also need a tiny health check route. In the App Router, that's just app/api/health/route.ts:

export async function GET() {
  return Response.json({ status: 'ok' });
}

Don't point your probes at / — if your homepage does a database call or fetches from a slow third-party API, a temporary slowdown there gets misread by Kubernetes as "the pod is dead," and it starts killing healthy pods. That's a self-inflicted outage.

The Service

apiVersion: v1
kind: Service
metadata:
  name: nextjs-service
spec:
  selector:
    app: nextjs-app
  ports:
    - protocol: TCP
      port: 80
      targetPort: 3000
  type: ClusterIP

ClusterIP is right here — you're not exposing this Service directly to the internet. That's the Ingress controller's job.

The Ingress

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: nextjs-ingress
  annotations:
    cert-manager.io/cluster-issuer: "letsencrypt-prod"
    nginx.ingress.kubernetes.io/proxy-body-size: "10m"
spec:
  ingressClassName: nginx
  tls:
    - hosts:
        - yourapp.com
      secretName: nextjs-tls
  rules:
    - host: yourapp.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: nextjs-service
                port:
                  number: 80

This assumes you're running an NGINX ingress controller and cert-manager for automatic TLS — a very common combination, but check what your cluster actually has installed before copying this blindly.

Environment variables and secrets — where people get sloppy

Next.js has a quirk that trips up almost everyone the first time: any variable prefixed with NEXT_PUBLIC_ gets baked into the JavaScript bundle at build time, not read at runtime. That means if you're building your Docker image once and deploying it across dev, staging, and production with different environment variables, your public env vars won't change — they're frozen into the bundle from whatever was set when npm run build ran.

You have two real options:

Build per environment

Run the Docker build separately for each environment with the right env vars injected at build time. Simpler mentally, slower in CI, and you end up with three different images instead of one promoted through stages.

Runtime config pattern

Keep public values that must change per-environment out of NEXT_PUBLIC_ and fetch them from an API route or a small runtime config endpoint instead. More setup, but one image gets promoted cleanly through every stage.

Server-only variables (database URLs, API keys) don't have this problem — they're read at request time, so a Kubernetes Secret works exactly as you'd expect:

apiVersion: v1
kind: Secret
metadata:
  name: nextjs-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgresql://user:pass@host:5432/db"

Reference it in the Deployment with secretKeyRef the same way the ConfigMap example above uses configMapKeyRef. Never commit a Secret manifest with real values to git — use Sealed Secrets, External Secrets Operator, or your cloud provider's secret manager if you want this managed properly instead of by discipline alone.

Scaling: HPA and what it actually does

A HorizontalPodAutoscaler watches a metric — usually CPU — and adds or removes pods to match:

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: nextjs-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: nextjs-app
  minReplicas: 3
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 65

Next.js apps doing server-side rendering are usually CPU-bound rather than memory-bound, so CPU-based scaling tends to work reasonably well out of the box. If your app leans heavily on data fetching and waiting on external APIs, you might get better results scaling on custom metrics like request latency or queue depth — that requires something like KEDA or the Prometheus adapter, which is worth mentioning but is its own separate rabbit hole.

Traffic spikes
CPU crosses 65%
HPA adds pods
Load balances across replicas
Traffic drops, pods scale down

Kubernetes vs. Vercel vs. traditional VMs

FactorKubernetesVercelPlain VM / VPS
Setup effortHigh — manifests, cluster, ingress, TLSMinutes — git push and doneMedium — server config, process manager
ScalingConfigurable, self-managed (HPA)Automatic, opaqueManual, or none
Cost at low trafficOften higher (cluster overhead)Cheap or free tierCheap
Cost at high, steady trafficCan be significantly cheaperScales with usage, can get priceyCheap but you manage everything
Best fitExisting k8s infra, compliance needs, custom scalingMarketing sites, most SaaS, small-to-mid appsSimple apps, tight budgets, full control fans

Common mistakes worth avoiding

Skipping standalone output. You'll ship the entire node_modules folder into your image, slowing every deploy and every pod restart.
Health-checking a heavy route. Point probes at a lightweight dedicated endpoint, not your homepage or an API route that touches a database.
Forgetting NEXT_PUBLIC_ is build-time. Teams lose hours debugging why "changing the env var in the cluster did nothing" — it's frozen in the bundle.
No resource limits. One pod can starve its neighbors on the same node, and you won't know why until it happens.
Running as root. Fine in a sandbox, rejected by any cluster enforcing Pod Security Standards, and just bad practice regardless.
Using latest as a tag. Rollbacks become guesswork, and stale image caching causes deployments that silently don't update.

A quick note on image optimization

Next.js's built-in <Image> component normally relies on an image optimization server that runs as part of the Next.js process itself — this works fine in Kubernetes since the standalone server handles it too, unlike some purely static hosting setups where it's disabled by default. If you're running at real scale, though, it's common to put a CDN in front of the Ingress and let it cache optimized images, taking that load off your pods entirely.

Wrapping up

None of this is exotic once you've done it once. The parts that actually matter — standalone builds, sane health checks, resource limits, and understanding when your env vars get baked in — are the same five or six things that bite every team the first time through. Get those right and the rest of Kubernetes' machinery (scaling, rolling updates, self-healing) mostly just works the way it's supposed to.

Whether it's worth the operational overhead compared to a managed platform is a real question, not a rhetorical one — answer it honestly based on your traffic, your team's existing Kubernetes comfort, and what you're already running. For a lot of teams, the right answer really is "not yet." For the ones where it makes sense, though, this setup will carry you a long way without needing much rework.

Frequently Asked Questions

Do I need a separate Dockerfile for development and production?

Not necessarily. Most teams use next dev locally without Docker at all, and reserve the multi-stage Dockerfile purely for the production build. If you do want Docker locally, a separate lightweight dev Dockerfile without the multi-stage build is usually less friction than trying to make one file serve both purposes.

Can I run Next.js API routes on Kubernetes, or do I need a separate backend?

API routes run fine inside the same Next.js server process, so there's no requirement to split them out. Some teams do split heavy backend logic into a separate service anyway, mainly for independent scaling — a good idea once your API traffic pattern diverges significantly from your page traffic.

How do I handle zero-downtime deployments?

Kubernetes' default RollingUpdate strategy handles this if your readiness probe is configured correctly — new pods won't receive traffic until they report ready, and old pods aren't terminated until replacements are healthy. Get the readiness probe wrong, though, and you'll get downtime despite the rolling update mechanism working exactly as designed.

Is Kubernetes overkill for a small Next.js project?

Usually, yes. If you're a solo developer or small team without existing Kubernetes infrastructure, the operational overhead rarely pays off until you have specific reasons — compliance, existing internal services, or very particular scaling needs — pulling you toward it.

What's the difference between readiness and liveness probes?

Readiness controls whether a pod receives traffic right now; liveness controls whether Kubernetes considers the pod alive at all and should restart it if it fails. A pod can be alive but temporarily not ready — say, during a slow startup — and that distinction matters for avoiding unnecessary restarts.

Should I use ISR (Incremental Static Regeneration) on Kubernetes?

You can, but be aware that ISR's on-disk cache is per-pod by default, so different pods can serve stale or inconsistent regenerated pages until each one independently revalidates. Teams running ISR at scale on Kubernetes often move to a shared cache backend to keep pods consistent.

How many replicas should I start with?

Three is a reasonable floor for production — it survives a single node failure and gives the rolling update strategy room to work without dipping below meaningful capacity. Tune upward based on actual load testing, not a guess.

Comments

Popular posts from this blog

React Performance Optimization: Profiling, Reconciliation, and Rendering Boundaries

Mastering React Icons: Installation, Customization, and Best Practices (2026 Guide)

How to Configure Webpack 5 with React from Scratch (2026 Guide)