Why put Next.js on Kubernetes at all
Most Next.js apps do not need Kubernetes. Vercel, a single VPS with Kamal, or Cloudflare Workers will serve you better and cost less. Kubernetes earns its complexity in a narrow set of situations: you already run other services on a cluster and want one deployment story, you need horizontal autoscaling tied to real request pressure, you operate under data-residency rules that rule out managed platforms, or you need several environments that are provably identical.
If that is your situation, the naive path — kubectl apply -f against a folder of hand-written YAML — falls apart within weeks. Manifests drift between environments, nobody knows which commit produced the running Pod, and rollbacks turn into archaeology.
This tutorial builds the disciplined version instead. By the end you will have:
- A Next.js 16 image built from
output: "standalone", typically 30–60% smaller than a naive build - A reusable Helm 4 chart with
values-staging.yamlandvalues-production.yaml - Liveness, readiness and startup probes wired to a real health endpoint
- A
HorizontalPodAutoscalerand aPodDisruptionBudgetso scaling and node drains do not cause downtime - Ingress with TLS via cert-manager
- Argo CD 3.3 continuously reconciling the cluster against Git, with automated sync, self-healing and one-command rollback
The pattern applies to any containerised Node.js app. Next.js is just the concrete example.
Prerequisites
You should be comfortable with Docker and have shipped a Next.js app to production somewhere before. You will need:
- Node.js 20+ and a Next.js 16 application (the App Router example below assumes 16.x)
- Docker 27+ for building images
- A Kubernetes cluster, version 1.33 or newer. For local work,
kindor Docker Desktop's built-in cluster is fine. For production, any managed offering (DOKS, EKS, GKE, AKS) works. - kubectl 1.34+, configured against your cluster
- Helm 4.2+ — this tutorial uses Helm 4 features that do not exist in Helm 3
- A container registry you can push to (GHCR, Docker Hub, or your cloud provider's)
- A Git repository that your cluster can read — this becomes the source of truth
Verify your toolchain:
kubectl version --output=yaml | grep gitVersion
helm version --short
docker --versionYou should see a Helm version of v4.2.x or later. If you get v3.x, upgrade before continuing — several steps below rely on Helm 4 behaviour.
Helm 4 renamed --force to --force-replace and no longer accepts an arbitrary executable for --post-renderer. If you are migrating an existing Helm 3 setup, audit your CI scripts for both before upgrading.
What you will build
Git repository (source of truth)
│
│ Argo CD watches this path
▼
charts/nextjs-app/ ← Helm chart
├── Chart.yaml
├── values.yaml ← defaults
├── values-staging.yaml
├── values-production.yaml
└── templates/
├── deployment.yaml
├── service.yaml
├── ingress.yaml
├── hpa.yaml
├── pdb.yaml
└── configmap.yaml
│
│ Argo CD renders + applies
▼
Kubernetes cluster
Ingress → Service → Deployment (3+ Pods, autoscaled)
A push to main updates the image tag in Git. Argo CD notices within seconds, renders the chart, applies the diff, and waits for the rollout to become healthy. No CI runner ever holds cluster credentials.
Step 1: Build a standalone Next.js image
The single biggest mistake in containerising Next.js is shipping the whole node_modules tree. Next.js can trace exactly which files the server actually imports and copy just those into .next/standalone.
Enable it in next.config.ts:
import type { NextConfig } from "next";
const nextConfig: NextConfig = {
// Traces server imports and emits a self-contained .next/standalone dir
output: "standalone",
// Kubernetes terminates TLS at the Ingress, so the app itself speaks HTTP.
// Trust the proxy headers so request.url and redirects use the public origin.
poweredByHeader: false,
experimental: {
// Emit a smaller server bundle by not inlining source maps in prod
serverSourceMaps: false,
},
};
export default nextConfig;Now the Dockerfile. Three stages keep layer caching clean and the final image small:
# syntax=docker/dockerfile:1.7
# ---- Stage 1: dependencies ----------------------------------------------
FROM node:20-alpine AS deps
WORKDIR /app
# Copy only the lockfile first so this layer is cached until deps change
COPY package.json pnpm-lock.yaml ./
RUN corepack enable && pnpm install --frozen-lockfile
# ---- Stage 2: build ------------------------------------------------------
FROM node:20-alpine AS builder
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY . .
# Build-time public vars must be present here — they are inlined into the bundle
ARG NEXT_PUBLIC_SITE_URL
ENV NEXT_PUBLIC_SITE_URL=$NEXT_PUBLIC_SITE_URL
ENV NEXT_TELEMETRY_DISABLED=1
RUN corepack enable && pnpm build
# ---- Stage 3: runtime ----------------------------------------------------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
ENV NEXT_TELEMETRY_DISABLED=1
ENV PORT=3000
ENV HOSTNAME=0.0.0.0
# Never run as root. Kubernetes will enforce this too, but defence in depth.
RUN addgroup --system --gid 1001 nodejs \
&& adduser --system --uid 1001 nextjs
# The standalone output already contains a minimal node_modules and server.js
COPY --from=builder --chown=nextjs:nodejs /app/.next/standalone ./
COPY --from=builder --chown=nextjs:nodejs /app/.next/static ./.next/static
COPY --from=builder --chown=nextjs:nodejs /app/public ./public
USER nextjs
EXPOSE 3000
CMD ["node", "server.js"]Two details that trip people up:
HOSTNAME=0.0.0.0 is mandatory. The standalone server binds to localhost by default, which means the kubelet cannot reach it and every readiness probe fails. This is the single most common reason a Next.js Pod sits in CrashLoopBackOff with no useful logs.
Static assets are copied separately. .next/standalone deliberately omits .next/static and public, because on Vercel those are served from a CDN. In a self-hosted cluster you must copy them in yourself.
Build and push:
export REGISTRY=ghcr.io/your-org
export IMAGE=$REGISTRY/nextjs-app
export TAG=$(git rev-parse --short HEAD)
docker build \
--build-arg NEXT_PUBLIC_SITE_URL=https://app.example.com \
-t $IMAGE:$TAG -t $IMAGE:latest .
docker push $IMAGE:$TAG
docker push $IMAGE:latestTag images with the Git SHA, never only latest. GitOps depends on the image tag being an immutable, traceable identifier — that is what lets you answer "which commit is running in production right now" without guessing.
Step 2: Add a real health endpoint
Kubernetes needs to distinguish three states: the process has started, the process is alive, and the process is ready to receive traffic. A route that returns 200 unconditionally answers none of those honestly.
Create app/api/health/route.ts:
import { NextResponse } from "next/server";
import { db } from "@/lib/db";
// Never cache a health check
export const dynamic = "force-dynamic";
export const revalidate = 0;
export async function GET() {
const checks: Record<string, "ok" | "fail"> = {};
// Liveness is about the process. Readiness is about dependencies.
// We report both and let the probe config decide what matters.
try {
await db.execute("SELECT 1");
checks.database = "ok";
} catch {
checks.database = "fail";
}
const healthy = Object.values(checks).every((v) => v === "ok");
return NextResponse.json(
{
status: healthy ? "healthy" : "degraded",
checks,
uptime: Math.floor(process.uptime()),
version: process.env.APP_VERSION ?? "unknown",
},
{ status: healthy ? 200 : 503 },
);
}Then a deliberately trivial liveness route at app/api/live/route.ts:
import { NextResponse } from "next/server";
export const dynamic = "force-dynamic";
// Liveness answers exactly one question: is the event loop still turning?
// It must NOT check the database — a database outage should drain traffic,
// not trigger an endless restart loop across every Pod simultaneously.
export async function GET() {
return NextResponse.json({ status: "alive" });
}That separation matters more than it looks. If your liveness probe checks the database and the database goes down for ninety seconds, Kubernetes restarts every Pod at once, and your app is still down when the database returns — now with cold caches and a thundering herd of reconnections.
Step 3: Scaffold the Helm chart
helm create charts/nextjs-app
rm -rf charts/nextjs-app/templates/tests charts/nextjs-app/templates/serviceaccount.yamlReplace charts/nextjs-app/Chart.yaml:
apiVersion: v2
name: nextjs-app
description: Production Next.js deployment
type: application
# version = chart version, bumped when templates change
version: 1.0.0
# appVersion = default app version, usually overridden per environment
appVersion: "1.0.0"
kubeVersion: ">=1.33.0-0"
maintainers:
- name: Platform Team
email: platform@example.comAnd the defaults in values.yaml:
replicaCount: 2
image:
repository: ghcr.io/your-org/nextjs-app
pullPolicy: IfNotPresent
tag: "" # falls back to .Chart.AppVersion when empty
nameOverride: ""
fullnameOverride: ""
service:
type: ClusterIP
port: 80
targetPort: 3000
ingress:
enabled: true
className: nginx
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/proxy-body-size: 10m
hosts:
- host: app.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: nextjs-app-tls
hosts:
- app.example.com
resources:
requests:
cpu: 100m
memory: 256Mi
limits:
# No CPU limit on purpose — see the note in Step 5
memory: 512Mi
autoscaling:
enabled: true
minReplicas: 2
maxReplicas: 10
targetCPUUtilizationPercentage: 70
targetMemoryUtilizationPercentage: 80
podDisruptionBudget:
enabled: true
minAvailable: 1
# Non-secret runtime configuration
env:
NODE_ENV: production
NEXT_TELEMETRY_DISABLED: "1"
# Names of pre-existing Secrets to project as env vars
envFromSecrets: []
probes:
startup:
failureThreshold: 30
periodSeconds: 2
liveness:
path: /api/live
initialDelaySeconds: 0
periodSeconds: 10
failureThreshold: 3
readiness:
path: /api/health
initialDelaySeconds: 0
periodSeconds: 5
failureThreshold: 3
nodeSelector: {}
tolerations: []
affinity: {}Step 4: Write the Deployment template
This is the heart of the chart. charts/nextjs-app/templates/deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: {{ include "nextjs-app.fullname" . }}
labels:
{{- include "nextjs-app.labels" . | nindent 4 }}
spec:
{{- if not .Values.autoscaling.enabled }}
replicas: {{ .Values.replicaCount }}
{{- end }}
revisionHistoryLimit: 5
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0 # never drop below the desired capacity
selector:
matchLabels:
{{- include "nextjs-app.selectorLabels" . | nindent 6 }}
template:
metadata:
annotations:
# Roll Pods automatically when non-secret config changes
checksum/config: {{ include (print $.Template.BasePath "/configmap.yaml") . | sha256sum }}
labels:
{{- include "nextjs-app.selectorLabels" . | nindent 8 }}
spec:
# Give in-flight requests time to finish before the process dies
terminationGracePeriodSeconds: 30
securityContext:
runAsNonRoot: true
runAsUser: 1001
fsGroup: 1001
seccompProfile:
type: RuntimeDefault
containers:
- name: {{ .Chart.Name }}
image: "{{ .Values.image.repository }}:{{ .Values.image.tag | default .Chart.AppVersion }}"
imagePullPolicy: {{ .Values.image.pullPolicy }}
securityContext:
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
capabilities:
drop: ["ALL"]
ports:
- name: http
containerPort: {{ .Values.service.targetPort }}
protocol: TCP
envFrom:
- configMapRef:
name: {{ include "nextjs-app.fullname" . }}-config
{{- range .Values.envFromSecrets }}
- secretRef:
name: {{ . }}
{{- end }}
env:
- name: APP_VERSION
value: {{ .Values.image.tag | default .Chart.AppVersion | quote }}
- name: POD_NAME
valueFrom:
fieldRef:
fieldPath: metadata.name
# Startup probe absorbs slow cold starts so liveness can stay aggressive
startupProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
failureThreshold: {{ .Values.probes.startup.failureThreshold }}
periodSeconds: {{ .Values.probes.startup.periodSeconds }}
livenessProbe:
httpGet:
path: {{ .Values.probes.liveness.path }}
port: http
periodSeconds: {{ .Values.probes.liveness.periodSeconds }}
failureThreshold: {{ .Values.probes.liveness.failureThreshold }}
readinessProbe:
httpGet:
path: {{ .Values.probes.readiness.path }}
port: http
periodSeconds: {{ .Values.probes.readiness.periodSeconds }}
failureThreshold: {{ .Values.probes.readiness.failureThreshold }}
lifecycle:
preStop:
exec:
# Let the Ingress controller notice the endpoint removal
# before the Node process starts shutting down.
command: ["sh", "-c", "sleep 5"]
resources:
{{- toYaml .Values.resources | nindent 12 }}
# readOnlyRootFilesystem requires writable mounts for anything
# Next.js writes at runtime
volumeMounts:
- name: cache
mountPath: /app/.next/cache
- name: tmp
mountPath: /tmp
volumes:
- name: cache
emptyDir: {}
- name: tmp
emptyDir: {}
{{- with .Values.nodeSelector }}
nodeSelector:
{{- toYaml . | nindent 8 }}
{{- end }}
{{- with .Values.tolerations }}
tolerations:
{{- toYaml . | nindent 8 }}
{{- end }}
# Spread Pods across nodes so one node failure cannot take the app down
topologySpreadConstraints:
- maxSkew: 1
topologyKey: kubernetes.io/hostname
whenUnsatisfiable: ScheduleAnyway
labelSelector:
matchLabels:
{{- include "nextjs-app.selectorLabels" . | nindent 14 }}Several choices here are worth defending explicitly.
maxUnavailable: 0 means a rollout adds a new Pod before removing an old one. You trade a little extra capacity during deploys for genuinely zero-downtime releases.
The preStop sleep exists because Pod termination and Endpoint removal are concurrent, not sequential. Without it, the Ingress controller can route a request to a Pod that has already begun shutting down. Five seconds of doing nothing is the standard fix.
readOnlyRootFilesystem: true is a meaningful hardening step, but Next.js writes to .next/cache for ISR and image optimisation. The emptyDir mounts give it somewhere to write without opening up the rest of the filesystem.
checksum/config forces a rolling restart whenever the ConfigMap changes. Without this annotation, editing config in Git changes the ConfigMap but leaves running Pods with the old values loaded — a genuinely confusing failure mode.
Step 5: Service, autoscaling and disruption budget
templates/service.yaml:
apiVersion: v1
kind: Service
metadata:
name: {{ include "nextjs-app.fullname" . }}
labels:
{{- include "nextjs-app.labels" . | nindent 4 }}
spec:
type: {{ .Values.service.type }}
ports:
- port: {{ .Values.service.port }}
targetPort: http
protocol: TCP
name: http
selector:
{{- include "nextjs-app.selectorLabels" . | nindent 4 }}templates/hpa.yaml:
{{- if .Values.autoscaling.enabled }}
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: {{ include "nextjs-app.fullname" . }}
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: {{ include "nextjs-app.fullname" . }}
minReplicas: {{ .Values.autoscaling.minReplicas }}
maxReplicas: {{ .Values.autoscaling.maxReplicas }}
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetCPUUtilizationPercentage }}
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: {{ .Values.autoscaling.targetMemoryUtilizationPercentage }}
behavior:
scaleUp:
# React quickly to traffic spikes
stabilizationWindowSeconds: 30
policies:
- type: Percent
value: 100
periodSeconds: 30
scaleDown:
# Shrink slowly to avoid flapping on bursty traffic
stabilizationWindowSeconds: 300
policies:
- type: Pods
value: 1
periodSeconds: 60
{{- end }}templates/pdb.yaml:
{{- if .Values.podDisruptionBudget.enabled }}
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: {{ include "nextjs-app.fullname" . }}
spec:
minAvailable: {{ .Values.podDisruptionBudget.minAvailable }}
selector:
matchLabels:
{{- include "nextjs-app.selectorLabels" . | nindent 6 }}
{{- end }}The disruption budget is easy to skip and expensive to omit. Without it, a node drain during a cluster upgrade can evict every Pod at once — your app goes down during scheduled maintenance you thought was safe.
Notice that values.yaml sets a memory limit but no CPU limit. CPU limits in Kubernetes are enforced by CFS throttling, which adds latency spikes to Node.js event loops even when the node has spare capacity. Set CPU requests so the scheduler places Pods sensibly, and leave CPU limits off unless you are running genuinely untrusted workloads.
Step 6: Configuration and secrets
templates/configmap.yaml handles non-sensitive values:
apiVersion: v1
kind: ConfigMap
metadata:
name: {{ include "nextjs-app.fullname" . }}-config
labels:
{{- include "nextjs-app.labels" . | nindent 4 }}
data:
{{- range $key, $value := .Values.env }}
{{ $key }}: {{ $value | quote }}
{{- end }}Secrets never belong in the chart. Reference Secrets that already exist in the cluster instead:
kubectl create secret generic nextjs-app-secrets \
--namespace production \
--from-literal=DATABASE_URL='postgresql://...' \
--from-literal=AUTH_SECRET='...'Then in values-production.yaml:
envFromSecrets:
- nextjs-app-secretsFor a real GitOps setup, create those Secrets through External Secrets Operator (pulling from Vault, AWS Secrets Manager, or Doppler) or Sealed Secrets (encrypted values that are safe to commit). Both keep the "everything is in Git" property without putting plaintext credentials there.
Now the environment overlays. values-staging.yaml:
replicaCount: 1
image:
tag: main-latest
ingress:
hosts:
- host: staging.example.com
paths:
- path: /
pathType: Prefix
tls:
- secretName: nextjs-app-staging-tls
hosts:
- staging.example.com
autoscaling:
enabled: false
podDisruptionBudget:
enabled: false
resources:
requests:
cpu: 50m
memory: 128Mi
limits:
memory: 256Mi
env:
NODE_ENV: production
NEXT_PUBLIC_ENVIRONMENT: staging
envFromSecrets:
- nextjs-app-secrets-stagingvalues-production.yaml:
replicaCount: 3
autoscaling:
enabled: true
minReplicas: 3
maxReplicas: 20
targetCPUUtilizationPercentage: 65
podDisruptionBudget:
enabled: true
minAvailable: 2
resources:
requests:
cpu: 250m
memory: 512Mi
limits:
memory: 1Gi
env:
NODE_ENV: production
NEXT_PUBLIC_ENVIRONMENT: production
envFromSecrets:
- nextjs-app-secretsStep 7: Validate and install with Helm 4
Never apply a chart you have not rendered. Lint first, then inspect the output:
helm lint charts/nextjs-app -f charts/nextjs-app/values-production.yaml
helm template nextjs-app charts/nextjs-app \
-f charts/nextjs-app/values-production.yaml \
--set image.tag=$TAG \
| lessRead that output properly. Most Kubernetes incidents are visible in the rendered manifest before they ever reach a cluster.
Install with server-side apply, which is Helm 4's default reconciliation strategy for new releases:
kubectl create namespace production --dry-run=client -o yaml | kubectl apply -f -
helm install nextjs-app charts/nextjs-app \
--namespace production \
-f charts/nextjs-app/values-production.yaml \
--set image.tag=$TAG \
--atomic \
--timeout 5m--atomic is the flag that turns a failed deploy into a non-event: if the release does not become healthy inside the timeout, Helm rolls back automatically instead of leaving you half-deployed.
Watch it come up:
kubectl -n production rollout status deployment/nextjs-app
kubectl -n production get pods -l app.kubernetes.io/name=nextjs-appIf a Pod is stuck, the useful commands in order are:
kubectl -n production describe pod <pod-name> # events, probe failures
kubectl -n production logs <pod-name> --previous # logs from the crashed container
kubectl -n production get events --sort-by=.lastTimestamp | tail -20For a subsequent upgrade, Helm 4 lets you migrate an existing release to server-side apply explicitly:
helm upgrade nextjs-app charts/nextjs-app \
--namespace production \
-f charts/nextjs-app/values-production.yaml \
--set image.tag=$NEW_TAG \
--server-side \
--atomicHelm 4 also lets a single values file contain multiple YAML documents separated by ---. That is genuinely useful for splitting a large values-production.yaml into commented sections without juggling six -f flags in CI.
Step 8: Hand over control to Argo CD
Everything so far still requires a human running helm upgrade — which means CI needs cluster credentials, and the cluster can silently drift from Git. Argo CD inverts that: it runs inside the cluster, pulls from Git, and continuously reconciles.
Install Argo CD 3.3:
kubectl create namespace argocd
kubectl apply -n argocd \
-f https://raw.githubusercontent.com/argoproj/argo-cd/v3.3.11/manifests/install.yaml
kubectl -n argocd rollout status deployment/argocd-serverGet the initial admin password and log in:
kubectl -n argocd get secret argocd-initial-admin-secret \
-o jsonpath="{.data.password}" | base64 -d; echo
kubectl -n argocd port-forward svc/argocd-server 8080:443
# then open https://localhost:8080Now define the production application declaratively. Commit this as argocd/production.yaml:
apiVersion: argoproj.io/v1alpha1
kind: Application
metadata:
name: nextjs-app-production
namespace: argocd
finalizers:
# Ensures deleting the Application also removes its resources
- resources-finalizer.argocd.argoproj.io
spec:
project: default
source:
repoURL: https://github.com/your-org/your-repo.git
targetRevision: main
path: charts/nextjs-app
helm:
valueFiles:
- values.yaml
- values-production.yaml
destination:
server: https://kubernetes.default.svc
namespace: production
syncPolicy:
automated:
# Delete resources that were removed from Git
prune: true
# Revert manual kubectl edits back to the Git state
selfHeal: true
syncOptions:
- CreateNamespace=true
- ServerSideApply=true
- PruneLast=true
retry:
limit: 5
backoff:
duration: 10s
factor: 2
maxDuration: 3m
revisionHistoryLimit: 10Apply it once, and from then on Git is the only interface:
kubectl apply -f argocd/production.yaml
argocd app get nextjs-app-productionThe two settings that change how your team works are prune and selfHeal. With selfHeal: true, someone running kubectl edit deployment in production gets their change silently reverted within seconds. That is the point — the cluster is a projection of Git, not a place you edit.
Turn on selfHeal in staging first and live with it for a week. Teams used to hotfixing directly against the cluster find it genuinely disruptive at first, and discovering that during a production incident is the wrong time.
Step 9: Close the loop from CI
The CI pipeline now has exactly two jobs: build an image, and write the new tag into Git. It never touches the cluster.
.github/workflows/deploy.yml:
name: Build and Deploy
on:
push:
branches: [main]
permissions:
contents: write
packages: write
jobs:
build:
runs-on: ubuntu-latest
outputs:
tag: ${{ steps.meta.outputs.tag }}
steps:
- uses: actions/checkout@v4
- id: meta
run: echo "tag=$(git rev-parse --short HEAD)" >> $GITHUB_OUTPUT
- uses: docker/setup-buildx-action@v3
- uses: docker/login-action@v3
with:
registry: ghcr.io
username: ${{ github.actor }}
password: ${{ secrets.GITHUB_TOKEN }}
- uses: docker/build-push-action@v6
with:
context: .
push: true
tags: ghcr.io/${{ github.repository }}:${{ steps.meta.outputs.tag }}
build-args: |
NEXT_PUBLIC_SITE_URL=https://app.example.com
cache-from: type=gha
cache-to: type=gha,mode=max
promote:
needs: build
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
# The entire "deployment" is a commit. Argo CD does the rest.
- name: Bump image tag in the production values file
run: |
sed -i "s|^ tag: .*| tag: \"${{ needs.build.outputs.tag }}\"|" \
charts/nextjs-app/values-production.yaml
- name: Commit and push
run: |
git config user.name "ci-bot"
git config user.email "ci-bot@example.com"
git add charts/nextjs-app/values-production.yaml
git commit -m "chore(deploy): production -> ${{ needs.build.outputs.tag }}"
git pushAdd an image.tag key to values-production.yaml for that sed to target:
image:
tag: "placeholder"Rolling back is now a Git operation:
# Revert the deploy commit — Argo CD syncs the previous image within seconds
git revert <deploy-commit-sha> && git push
# Or, for an emergency, roll back directly in Argo CD
argocd app history nextjs-app-production
argocd app rollback nextjs-app-production <history-id>Note that an Argo CD rollback leaves the cluster out of sync with Git deliberately. Follow it with a Git revert, or selfHeal will drag the broken version straight back.
Testing your deployment
Verify each layer independently rather than just loading the site once.
The image runs standalone:
docker run --rm -p 3000:3000 -e HOSTNAME=0.0.0.0 $IMAGE:$TAG
curl -f http://localhost:3000/api/liveThe probes behave correctly under failure. Scale your database down and confirm that readiness fails while liveness still passes — Pods should leave the load balancer without restarting:
kubectl -n production get pods -w
# READY should go 1/1 -> 0/1, but RESTARTS must stay at 0Rollouts are genuinely zero-downtime. Run a load generator against the Ingress while triggering a deploy:
kubectl run loadtest --rm -it --image=williamyeh/hey -- \
-z 120s -c 20 https://app.example.com/api/liveAny non-2xx responses point at a missing preStop hook, a readiness probe that returns 200 too early, or maxUnavailable above zero.
Autoscaling reacts:
kubectl -n production get hpa nextjs-app -wUnder sustained load, replicas should climb within about a minute and drift back down over the five-minute stabilisation window.
Self-healing works. Prove the GitOps loop is real:
kubectl -n production scale deployment nextjs-app --replicas=1
sleep 20
kubectl -n production get deployment nextjs-app # back to the Git-declared countTroubleshooting
Pods in CrashLoopBackOff with empty logs. Almost always the HOSTNAME binding. Confirm with kubectl exec into a running Pod and check that the server is listening on 0.0.0.0:3000, not 127.0.0.1:3000.
404s on CSS and JavaScript, HTML loads fine. You did not copy .next/static into the runtime stage, or you copied it to the wrong path. It must land at /app/.next/static.
EROFS: read-only file system in the logs. Next.js is trying to write somewhere you have not mounted. Add an emptyDir for that path, or set outputFileTracingRoot so it writes inside /app/.next/cache.
ISR pages behave inconsistently across Pods. Each Pod has its own emptyDir cache, so revalidation on one Pod is invisible to the others. Configure a shared cache handler backed by Redis via cacheHandler in next.config.ts, or accept per-Pod caching and shorten your revalidate windows.
Argo CD sits permanently OutOfSync with no visible diff. Usually a mutating admission webhook (a service mesh sidecar injector, for instance) modifying resources after apply. Add ServerSideApply=true — which the manifest above already does — or annotate the specific field with argocd.argoproj.io/compare-options: IgnoreExtraneous.
Helm upgrade fails with a field-ownership conflict. This is server-side apply working as intended: something else edited a field Helm manages. Identify the other owner with kubectl get deployment nextjs-app -o yaml | grep -A20 managedFields, then either remove the conflicting controller or pass --force-conflicts=true if Helm should genuinely win.
Traffic drops during node upgrades. Your PodDisruptionBudget is missing or minAvailable is too low relative to replicaCount.
Next steps
The cluster is running and reconciling. Three directions add the most value next:
- Observability. Ship traces from the app itself — Next.js with OpenTelemetry pairs naturally with cluster-level Prometheus metrics, and Sentry for Next.js 16 covers error tracking across Pod restarts.
- Progressive delivery. Argo Rollouts replaces the
Deploymentwith aRolloutresource supporting canary and blue-green strategies, with automated promotion gated on real metrics. - Multi-environment scale. Argo CD's ApplicationSet generator creates one Application per environment or per preview branch from a single template, which is how you get ephemeral preview environments on your own infrastructure.
If this whole setup feels heavier than your app warrants — it probably is. Kamal 2 on a VPS delivers zero-downtime deploys with a fraction of the moving parts, and Coolify gives you a self-hosted platform experience. Reach for Kubernetes when you have several services, real scaling requirements, or a platform team to own it.
Conclusion
The pieces that make this production-grade are not the Kubernetes primitives themselves — they are the details around them. A standalone image bound to 0.0.0.0. Liveness and readiness probes that answer different questions, so a database outage drains traffic instead of restarting every Pod. maxUnavailable: 0 and a preStop hook that together make rollouts genuinely seamless. A disruption budget so scheduled maintenance is not an outage. CPU requests without CPU limits, to keep the event loop off the CFS throttle.
Layered on top, Helm 4 gives you one chart with per-environment values and atomic upgrades that roll themselves back, while Argo CD makes Git the only way to change the cluster. Deploys become commits, rollbacks become reverts, and "what is running in production" stops being a question anyone has to guess at.