Skip to main content

Deploying Next.js 16 to Kubernetes: The Complete Production Guide

18 min read
Deploy my Next.js on Kubernetes

We write the multi-stage Dockerfile, manifests, health checks and HPA, and run it on K3s.

A complete guide to deploying Next.js 16 to Kubernetes in production, including multi-stage Dockerfile, K3s deployment manifests, health checks, HPA, Cloudflare Tunnel integration, environment variables, and Prisma in containers.

Introduction

Next.js has become one of the most popular frameworks for building full-stack web applications. But deploying it to Kubernetes in production requires careful attention to container optimization, health checks, graceful shutdown, and caching. This guide covers everything we need to go from a Next.js 16 project to a production-ready Kubernetes deployment on K3s.

We assume familiarity with Next.js basics and basic Kubernetes concepts. By the end, we will have a complete Dockerfile, Kubernetes manifests, Horizontal Pod Autoscaler, and Cloudflare Tunnel integration.

1. Configuring Next.js for Standalone Output

The standalone output mode is essential for containerized deployments. It bundles only the files needed to run the application, drastically reducing image size.

In next.config.ts:

import type { NextConfig } from "next";

const nextConfig: NextConfig = {
  output: "standalone",
};

export default nextConfig;

2. Multi-Stage Dockerfile

This Dockerfile produces a minimal production image:

# Stage 1: Install dependencies
FROM node:22-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
# If using Prisma, copy the schema before install
COPY prisma ./prisma/
RUN npm ci --ignore-scripts
# Generate Prisma client if needed
RUN npx prisma generate

# Stage 2: Build the application
FROM node:22-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .

# Build-time environment variables
ARG NEXT_PUBLIC_API_URL
ENV NEXT_PUBLIC_API_URL=${NEXT_PUBLIC_API_URL}

RUN npm run build

# Stage 3: Production image
FROM node:22-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 the standalone output
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public

# If using Prisma, copy the generated client
COPY --from=deps /app/node_modules/.prisma ./node_modules/.prisma
COPY --from=deps /app/node_modules/@prisma ./node_modules/@prisma

USER nextjs

EXPOSE 3000
ENV PORT=3000
ENV HOSTNAME="0.0.0.0"

# Graceful shutdown handler
CMD ["node", "server.js"]

Key points:

  • Three stages keep the final image small (typically 150-200 MB vs 1 GB+ with a naive Dockerfile).
  • Non-root user for security.
  • Static files copied separately because the standalone output does not include them.
  • Prisma client must be explicitly copied to the runner stage.

3. Handling Environment Variables

Next.js has two types of environment variables:

  • NEXT_PUBLIC_*: Inlined at build time into the client bundle. Must be available during npm run build.
  • Server-side variables: Available at runtime via process.env.

For Kubernetes, we pass build-time variables as Docker build args and runtime variables through the Kubernetes Deployment:

# Build with public env vars
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  -t myapp:v1.0.0 .

4. Kubernetes Deployment Manifests

Namespace

apiVersion: v1
kind: Namespace
metadata:
  name: myapp

Deployment

apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
  namespace: myapp
spec:
  replicas: 3
  revisionHistoryLimit: 5
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      terminationGracePeriodSeconds: 30
      containers:
        - name: myapp
          image: registry.example.com/myapp:v1.0.0
          ports:
            - containerPort: 3000
              name: http
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: database-url
            - name: REDIS_URL
              value: "redis://redis-svc.myapp:6379"
          resources:
            requests:
              cpu: 200m
              memory: 256Mi
            limits:
              cpu: "1"
              memory: 512Mi
          readinessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 10
            failureThreshold: 3
          livenessProbe:
            httpGet:
              path: /api/health
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 30
            failureThreshold: 3
          lifecycle:
            preStop:
              exec:
                command: ["/bin/sh", "-c", "sleep 5"]

Service

apiVersion: v1
kind: Service
metadata:
  name: myapp
  namespace: myapp
spec:
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 3000
      name: http
  type: ClusterIP

Health Check Endpoint

Create a simple API route for health checks:

// app/api/health/route.ts
import { NextResponse } from "next/server";

export async function GET() {
  // Add database connectivity check if needed
  return NextResponse.json({ status: "ok" }, { status: 200 });
}

5. Horizontal Pod Autoscaler

apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: myapp
  namespace: myapp
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: myapp
  minReplicas: 3
  maxReplicas: 15
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70
    - type: Resource
      resource:
        name: memory
        target:
          type: Utilization
          averageUtilization: 80
  behavior:
    scaleDown:
      stabilizationWindowSeconds: 300
      policies:
        - type: Pods
          value: 1
          periodSeconds: 60

6. Cloudflare Tunnel Integration

Instead of exposing the cluster with a cloud load balancer, we use Cloudflare Tunnel:

apiVersion: apps/v1
kind: Deployment
metadata:
  name: cloudflared
  namespace: myapp
spec:
  replicas: 2
  selector:
    matchLabels:
      app: cloudflared
  template:
    metadata:
      labels:
        app: cloudflared
    spec:
      containers:
        - name: cloudflared
          image: cloudflare/cloudflared:latest
          args:
            - tunnel
            - --no-autoupdate
            - run
            - --token
            - $(TUNNEL_TOKEN)
          env:
            - name: TUNNEL_TOKEN
              valueFrom:
                secretKeyRef:
                  name: cloudflare-tunnel
                  key: token

Configure the tunnel to route traffic to the myapp service:

tunnel: <TUNNEL_ID>
ingress:
  - hostname: myapp.example.com
    service: http://myapp.myapp.svc.cluster.local:80
  - service: http_status:404

7. Prisma in Containers

When using Prisma ORM, we need to handle migrations carefully:

# Run migrations as a Kubernetes Job before deploying
kubectl create job prisma-migrate --image=registry.example.com/myapp:v1.0.0 \
  -- npx prisma migrate deploy

Or as a pre-deploy Job in the manifests:

apiVersion: batch/v1
kind: Job
metadata:
  name: prisma-migrate
  namespace: myapp
spec:
  template:
    spec:
      containers:
        - name: migrate
          image: registry.example.com/myapp:v1.0.0
          command: ["npx", "prisma", "migrate", "deploy"]
          env:
            - name: DATABASE_URL
              valueFrom:
                secretKeyRef:
                  name: myapp-secrets
                  key: database-url
      restartPolicy: Never
  backoffLimit: 3

8. Build and Deploy Script

#!/usr/bin/env bash
set -euo pipefail

TAG=$(git rev-parse --short HEAD)
REGISTRY="registry.example.com/myapp"

echo "Building image with tag: $TAG"
docker build \
  --build-arg NEXT_PUBLIC_API_URL=https://api.example.com \
  -t "$REGISTRY:$TAG" \
  -t "$REGISTRY:latest" .

echo "Pushing image..."
docker push "$REGISTRY:$TAG"
docker push "$REGISTRY:latest"

echo "Updating deployment..."
kubectl set image deployment/myapp myapp="$REGISTRY:$TAG" -n myapp

echo "Waiting for rollout..."
kubectl rollout status deployment/myapp -n myapp --timeout=300s

echo "Deployment complete!"

Deploying Next.js to Kubernetes requires attention to standalone output configuration, proper Dockerfile layering, health checks, and graceful shutdown. With the manifests in this guide, we get rolling deployments with zero downtime, automatic scaling based on CPU and memory, and secure exposure via Cloudflare Tunnel. The complete setup runs reliably on even a single K3s node for smaller projects and scales horizontally across multiple nodes for high-traffic applications.

Frequently asked questions

How do I configure Next.js 16 for Docker deployment?

Set output to standalone in next.config.ts, which bundles only the files the app needs to run and cuts the image size drastically. The standalone folder leaves out .next/static and public, so the Dockerfile has to copy both in separately. The container then starts with node server.js.

What does a production Dockerfile for Next.js 16 look like?

A production Next.js Dockerfile uses three stages on node:22-alpine. The deps stage runs npm ci --ignore-scripts and npx prisma generate, the builder runs npm run build with any NEXT_PUBLIC_ build args, and the runner copies the standalone output, static files and public folder, then runs as a non-root nextjs user with UID 1001. The final image is typically 150-200 MB, against 1 GB or more for a naive Dockerfile.

Why are NEXT_PUBLIC environment variables missing in a Next.js Kubernetes deployment?

NEXT_PUBLIC_ variables are inlined into the client bundle at build time, so they must be present during npm run build. Pass them as Docker build args, for example docker build --build-arg NEXT_PUBLIC_API_URL set to the API address, with a matching ARG and ENV in the builder stage. Server-side variables are read from process.env at runtime and go in the Kubernetes Deployment, with values such as DATABASE_URL pulled from a Secret.

How do I add a health check endpoint to Next.js for Kubernetes probes?

Create app/api/health/route.ts with a GET handler that returns NextResponse.json with status ok and HTTP 200, and add a database connectivity check if needed. Point both probes at /api/health on port 3000. A workable pattern is a readiness probe every 10 seconds after a 5-second delay and a liveness probe every 30 seconds after 10 seconds, each with failureThreshold 3.

How do I set up autoscaling for Next.js on Kubernetes?

A HorizontalPodAutoscaler on autoscaling/v2 can keep between 3 and 15 replicas, with targets of 70 percent average CPU utilization and 80 percent average memory utilization. Scale-down is slowed by a 300-second stabilization window and a policy that removes at most one pod every 60 seconds. Each pod requests 200m CPU and 256Mi of memory, with limits of 1 CPU and 512Mi.

How do I expose Next.js on K3s with Cloudflare Tunnel?

Run cloudflared inside the cluster as a Deployment with two replicas of the cloudflare/cloudflared image, started with tunnel --no-autoupdate run --token and the token read from a Kubernetes Secret. The tunnel's ingress rules send the app hostname to the internal service, myapp.myapp.svc.cluster.local on port 80, with http_status:404 as the catch-all. No cloud load balancer is needed in front of the cluster.

How do I run Prisma migrations for Next.js on Kubernetes?

Migrations go in a Kubernetes Job that runs npx prisma migrate deploy with the app image before the new version rolls out. A one-off kubectl create job prisma-migrate --image=registry.example.com/myapp:v1.0.0 -- npx prisma migrate deploy works, as does a batch/v1 Job in the manifests with DATABASE_URL from a Secret, restartPolicy Never and backoffLimit 3. The image itself must copy the generated .prisma and @prisma folders into the runner stage.

How do I roll out a new Next.js image on Kubernetes without downtime?

Tag each build with git rev-parse --short HEAD, push it, run kubectl set image deployment/myapp with the new tag in the myapp namespace, and wait on kubectl rollout status with --timeout=300s. Zero downtime comes from the Deployment, which uses maxSurge 1 and maxUnavailable 0, a 30-second termination grace period and a preStop hook that sleeps 5 seconds before shutdown.

Read how we handle it in Cloud & Server Management.