writing/tutorial/2026/07
TutorialJul 28, 2026·35 min read

Deploy Next.js to Kubernetes with Helm 4 and Argo CD GitOps

A production walkthrough for running Next.js 16 on Kubernetes: a minimal standalone Docker image, a Helm 4 chart with server-side apply, health probes and autoscaling, then continuous delivery driven by Argo CD 3.3 GitOps.

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.yaml and values-production.yaml
  • Liveness, readiness and startup probes wired to a real health endpoint
  • A HorizontalPodAutoscaler and a PodDisruptionBudget so 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, kind or 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 --version

You 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:latest

Tag 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.yaml

Replace 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.com

And 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-secrets

For 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-staging

values-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-secrets

Step 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 \
  | less

Read 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-app

If 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 -20

For 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 \
  --atomic

Helm 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-server

Get 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:8080

Now 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: 10

Apply it once, and from then on Git is the only interface:

kubectl apply -f argocd/production.yaml
argocd app get nextjs-app-production

The 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 push

Add 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/live

The 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 0

Rollouts 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/live

Any 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 -w

Under 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 count

Troubleshooting

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:

  1. 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.
  2. Progressive delivery. Argo Rollouts replaces the Deployment with a Rollout resource supporting canary and blue-green strategies, with automated promotion gated on real metrics.
  3. 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.