Containerization

Kubernetes Fundamentals for Application Developers

A developer-focused introduction to Kubernetes covering Pods, Deployments, Services, ConfigMaps, health checks, and deploying a Node.js Express app with PostgreSQL step by step.

Kubernetes Fundamentals for Application Developers

Kubernetes is the industry-standard container orchestration platform that automates the deployment, scaling, and management of containerized applications. If you are shipping containers to production, you will encounter Kubernetes eventually — and understanding it from a developer's perspective will make you dramatically more effective. This guide focuses on what application developers actually need to know: writing manifests, deploying services, debugging pods, and connecting the pieces together.

Prerequisites

Before diving in, you should have:

  • Working knowledge of Docker (building images, writing Dockerfiles, running containers)
  • Familiarity with the command line
  • A basic understanding of networking concepts (ports, DNS, TCP)
  • Node.js installed locally (v18+)
  • Docker Desktop installed (includes a single-node Kubernetes cluster) or minikube
  • kubectl CLI installed and on your PATH

What Kubernetes Actually Solves

Before you invest time learning Kubernetes, understand what problems it addresses. If you do not have these problems, you do not need Kubernetes.

Scaling. Kubernetes can run multiple copies of your application and distribute traffic across them. When load increases, it can automatically spin up more replicas. When load drops, it scales back down.

Self-healing. If a container crashes, Kubernetes restarts it. If a node goes down, Kubernetes reschedules the workloads onto healthy nodes. You declare the desired state, and Kubernetes continuously reconciles reality to match.

Rolling updates with zero downtime. Kubernetes can roll out a new version of your application incrementally — replacing old pods with new ones — so that your service never goes offline during a deployment.

Service discovery and load balancing. Kubernetes gives every service a stable DNS name and IP address, and routes traffic to healthy pods automatically. No more hardcoding IP addresses or managing load balancers by hand.

Configuration management. Secrets and configuration values are decoupled from your container images, making it straightforward to run the same image in dev, staging, and production with different configs.

When Kubernetes Is Overkill

I need to say this plainly: if you have fewer than five services and a small team, Kubernetes is almost certainly overkill. The operational complexity is real. You need to understand networking, RBAC, ingress controllers, persistent storage, monitoring, and more.

For small to medium applications, consider these alternatives first:

  • DigitalOcean App Platform — push your code, get HTTPS, autoscaling, and managed databases with zero infrastructure knowledge
  • AWS ECS with Fargate — run containers without managing servers, simpler than EKS
  • Railway or Render — deploy from a Git repo with minimal configuration
  • A single VM with Docker Compose — seriously, for many workloads this is fine

Kubernetes makes sense when you have many services, need fine-grained control over scaling and networking, or your organization already has a platform team managing clusters.

With that caveat out of the way, let us learn Kubernetes.

Key Concepts for Developers

Kubernetes has a lot of abstractions. Here are the ones you will interact with daily.

Pods

A Pod is the smallest deployable unit in Kubernetes. It wraps one or more containers that share networking and storage. In practice, most Pods contain a single container.

You rarely create Pods directly. Instead, you create Deployments, which manage Pods for you.

Deployments

A Deployment tells Kubernetes: "I want N replicas of this container running at all times." It handles creating Pods, scaling them, and performing rolling updates when you push a new image.

Services

A Service provides a stable network endpoint for a set of Pods. Pods are ephemeral — they come and go. Services give you a consistent DNS name and IP to reach your application.

ConfigMaps

A ConfigMap stores non-sensitive configuration data as key-value pairs. You mount them as environment variables or files inside your containers.

Secrets

A Secret is like a ConfigMap but for sensitive data — database passwords, API keys, TLS certificates. The values are base64-encoded (not encrypted by default — use an external secrets manager for real security).

Namespaces

Namespaces partition a cluster into virtual sub-clusters. Use them to isolate environments (dev, staging, production) or teams within the same physical cluster.

Setting Up a Local Cluster

The fastest way to get a local Kubernetes cluster is Docker Desktop. Go to Settings > Kubernetes > Enable Kubernetes. Wait a few minutes for it to start.

Alternatively, install minikube:

# macOS
brew install minikube

# Windows (with chocolatey)
choco install minikube

# Start the cluster
minikube start

# Verify it works
kubectl cluster-info

Confirm your cluster is running:

$ kubectl get nodes
NAME             STATUS   ROLES           AGE   VERSION
docker-desktop   Ready    control-plane   12d   v1.29.1

Your First Pod Manifest

Every Kubernetes resource is defined in a YAML manifest. Here is a minimal Pod:

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello-node
  labels:
    app: hello-node
spec:
  containers:
    - name: hello-node
      image: node:20-alpine
      command: ["node", "-e", "require('http').createServer(function(req, res) { res.end('Hello from Kubernetes'); }).listen(3000)"]
      ports:
        - containerPort: 3000

Apply it:

$ kubectl apply -f pod.yaml
pod/hello-node created

$ kubectl get pods
NAME         READY   STATUS    RESTARTS   AGE
hello-node   1/1     Running   0          8s

Delete it when done:

kubectl delete pod hello-node

You almost never create bare Pods in practice. Use a Deployment instead.

Deployments: Managing Replicas and Updates

A Deployment is what you actually use to run your application. It manages a ReplicaSet, which manages Pods.

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api-server
  labels:
    app: api-server
spec:
  replicas: 3
  selector:
    matchLabels:
      app: api-server
  template:
    metadata:
      labels:
        app: api-server
    spec:
      containers:
        - name: api-server
          image: myregistry/api-server:1.0.0
          ports:
            - containerPort: 3000
          env:
            - name: NODE_ENV
              value: "production"

Key fields:

  • replicas: 3 — Kubernetes will maintain exactly three running instances
  • selector.matchLabels — how the Deployment finds its Pods
  • template — the Pod specification used to create each replica

Apply it and watch the rollout:

$ kubectl apply -f deployment.yaml
deployment.apps/api-server created

$ kubectl rollout status deployment/api-server
Waiting for deployment "api-server" rollout to finish: 0 of 3 updated replicas are available...
Waiting for deployment "api-server" rollout to finish: 1 of 3 updated replicas are available...
Waiting for deployment "api-server" rollout to finish: 2 of 3 updated replicas are available...
deployment "api-server" successfully rolled out

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
api-server-6d4f8b7c95-2xkrp   1/1     Running   0          32s
api-server-6d4f8b7c95-7wnqm   1/1     Running   0          32s
api-server-6d4f8b7c95-lp4xj   1/1     Running   0          32s

To update your application, change the image tag and reapply:

kubectl set image deployment/api-server api-server=myregistry/api-server:1.1.0

Kubernetes performs a rolling update by default — replacing old Pods with new ones incrementally.

Services: Networking Your Application

Pods get IP addresses, but those IPs change every time a Pod restarts. A Service gives you a stable endpoint.

There are three Service types you need to know:

ClusterIP (default)

Only accessible from within the cluster. Use this for internal service-to-service communication.

# service-clusterip.yaml
apiVersion: v1
kind: Service
metadata:
  name: api-server
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
  type: ClusterIP

Other Pods in the cluster can now reach your app at http://api-server:80.

NodePort

Exposes the service on a static port on each node's IP. Useful for development and testing.

apiVersion: v1
kind: Service
metadata:
  name: api-server-nodeport
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
      nodePort: 30080
  type: NodePort

Access it at http://localhost:30080 on Docker Desktop.

LoadBalancer

Provisions an external load balancer (on cloud providers). This is what you use in production to expose a service to the internet.

apiVersion: v1
kind: Service
metadata:
  name: api-server-lb
spec:
  selector:
    app: api-server
  ports:
    - port: 80
      targetPort: 3000
  type: LoadBalancer

ConfigMaps and Secrets

Keep configuration out of your images. This is not optional — it is one of the twelve-factor app principles.

ConfigMap

# configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
data:
  LOG_LEVEL: "info"
  MAX_CONNECTIONS: "100"
  CACHE_TTL: "300"

Reference it in your Deployment:

env:
  - name: LOG_LEVEL
    valueFrom:
      configMapKeyRef:
        name: api-config
        key: LOG_LEVEL

Or mount all keys as environment variables at once:

envFrom:
  - configMapRef:
      name: api-config

Secret

# Create a secret from the command line
kubectl create secret generic db-credentials \
  --from-literal=DB_USER=appuser \
  --from-literal=DB_PASSWORD=s3cureP@ssw0rd

Or define it in YAML (values must be base64-encoded):

# secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: db-credentials
type: Opaque
data:
  DB_USER: YXBwdXNlcg==
  DB_PASSWORD: czNjdXJlUEBzc3cwcmQ=

Reference in your Deployment:

env:
  - name: DB_USER
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: DB_USER
  - name: DB_PASSWORD
    valueFrom:
      secretKeyRef:
        name: db-credentials
        key: DB_PASSWORD

Health Checks: Readiness, Liveness, and Startup Probes

Health checks are critical. Without them, Kubernetes has no way to know if your application is actually working.

Liveness Probe

"Is this container alive?" If the liveness probe fails, Kubernetes kills the container and restarts it.

Readiness Probe

"Is this container ready to accept traffic?" If the readiness probe fails, Kubernetes removes the Pod from the Service's endpoints — no traffic gets routed to it.

Startup Probe

"Has this container finished starting?" Useful for applications with slow startup times. Disables liveness checks until the startup probe succeeds.

Here is a Node.js app with proper health check endpoints:

// server.js
var express = require("express");
var app = express();
var isReady = false;

// Simulate initialization (database connections, cache warming, etc.)
setTimeout(function() {
  isReady = true;
  console.log("Application is ready to accept traffic");
}, 5000);

app.get("/healthz", function(req, res) {
  // Liveness: is the process alive and responsive?
  res.status(200).json({ status: "alive" });
});

app.get("/readyz", function(req, res) {
  // Readiness: is the app ready to handle requests?
  if (isReady) {
    res.status(200).json({ status: "ready" });
  } else {
    res.status(503).json({ status: "not ready" });
  }
});

app.get("/", function(req, res) {
  res.json({ message: "Hello from Kubernetes" });
});

var port = process.env.PORT || 3000;
app.listen(port, function() {
  console.log("Server listening on port " + port);
});

And the corresponding probe configuration in your Deployment:

containers:
  - name: api-server
    image: myregistry/api-server:1.0.0
    ports:
      - containerPort: 3000
    livenessProbe:
      httpGet:
        path: /healthz
        port: 3000
      initialDelaySeconds: 10
      periodSeconds: 15
      failureThreshold: 3
    readinessProbe:
      httpGet:
        path: /readyz
        port: 3000
      initialDelaySeconds: 5
      periodSeconds: 5
      failureThreshold: 3
    startupProbe:
      httpGet:
        path: /healthz
        port: 3000
      failureThreshold: 30
      periodSeconds: 2

Resource Requests and Limits

Always set resource requests and limits. Without them, a single runaway process can starve every other workload on the node.

resources:
  requests:
    cpu: "100m"       # 0.1 CPU cores — used for scheduling
    memory: "128Mi"   # 128 MiB — used for scheduling
  limits:
    cpu: "500m"       # 0.5 CPU cores — hard ceiling
    memory: "256Mi"   # 256 MiB — container is OOM-killed if exceeded

Requests are what the scheduler uses to place your Pod on a node. Limits are hard ceilings. If your container exceeds the memory limit, it gets killed. If it exceeds the CPU limit, it gets throttled.

For Node.js applications, a typical starting point:

  • Request: 100m CPU, 128Mi memory
  • Limit: 500m CPU, 512Mi memory
  • Adjust based on actual usage observed with kubectl top pods

Namespaces for Environment Isolation

Namespaces let you run multiple environments in the same cluster:

# Create namespaces
kubectl create namespace development
kubectl create namespace staging
kubectl create namespace production

# Deploy to a specific namespace
kubectl apply -f deployment.yaml -n staging

# List pods in a namespace
kubectl get pods -n staging

# Set a default namespace for your context
kubectl config set-context --current --namespace=development

Use namespaces to apply different resource quotas, network policies, and RBAC rules per environment.

kubectl Commands Every Developer Needs

Here is your day-to-day cheat sheet:

# --- Viewing Resources ---
kubectl get pods                        # List pods
kubectl get pods -o wide                # List pods with node and IP info
kubectl get deployments                 # List deployments
kubectl get services                    # List services
kubectl get all                         # List everything in the namespace

# --- Inspecting Resources ---
kubectl describe pod <pod-name>         # Detailed pod info (events, status)
kubectl describe deployment <name>      # Deployment details
kubectl get pod <pod-name> -o yaml      # Full YAML definition

# --- Logs ---
kubectl logs <pod-name>                 # View container logs
kubectl logs <pod-name> -f              # Stream logs in real time
kubectl logs <pod-name> --previous      # Logs from the previous crashed container
kubectl logs -l app=api-server          # Logs from all pods with a label

# --- Debugging ---
kubectl exec -it <pod-name> -- /bin/sh  # Shell into a running container
kubectl port-forward <pod-name> 3000:3000  # Forward local port to pod
kubectl top pods                        # CPU and memory usage

# --- Managing Resources ---
kubectl apply -f manifest.yaml          # Create or update resources
kubectl delete -f manifest.yaml         # Delete resources defined in a file
kubectl scale deployment <name> --replicas=5  # Scale manually
kubectl rollout undo deployment <name>  # Roll back to previous version
kubectl rollout history deployment <name>  # View rollout history

Deploying a Node.js Express App Step by Step

Let us walk through a real deployment from start to finish.

Step 1: Create the Application

// app.js
var express = require("express");
var app = express();

app.use(express.json());

app.get("/", function(req, res) {
  res.json({
    service: "user-api",
    version: "1.0.0",
    timestamp: new Date().toISOString()
  });
});

app.get("/healthz", function(req, res) {
  res.status(200).json({ status: "ok" });
});

app.get("/readyz", function(req, res) {
  res.status(200).json({ status: "ready" });
});

app.get("/users", function(req, res) {
  res.json([
    { id: 1, name: "Alice" },
    { id: 2, name: "Bob" }
  ]);
});

var port = process.env.PORT || 3000;
app.listen(port, function() {
  console.log("user-api listening on port " + port);
});

Step 2: Write the Dockerfile

FROM node:20-alpine

WORKDIR /app

COPY package*.json ./
RUN npm ci --only=production

COPY . .

EXPOSE 3000

USER node

CMD ["node", "app.js"]

Step 3: Build and Push the Image

docker build -t myregistry/user-api:1.0.0 .
docker push myregistry/user-api:1.0.0

Step 4: Write the Kubernetes Manifests

See the complete working example in the next section.

Connecting to a Database in Kubernetes

Running a database in Kubernetes requires a PersistentVolumeClaim (PVC) to ensure data survives pod restarts.

# postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

The database Deployment mounts this volume:

volumeMounts:
  - name: postgres-storage
    mountPath: /var/lib/postgresql/data
volumes:
  - name: postgres-storage
    persistentVolumeClaim:
      claimName: postgres-data

Your application connects to the database using the Service DNS name. If your PostgreSQL Service is named postgres, your connection string is:

postgresql://appuser:password@postgres:5432/mydb

Kubernetes DNS automatically resolves postgres to the ClusterIP of the postgres Service within the same namespace.

Viewing Logs and Debugging Pods

When things go wrong — and they will — here is how to investigate:

# Check pod status first
$ kubectl get pods
NAME                          READY   STATUS             RESTARTS   AGE
api-server-6d4f8b7c95-2xkrp   1/1     Running            0          5m
api-server-6d4f8b7c95-7wnqm   0/1     CrashLoopBackOff   4          5m

# Get details on the failing pod
$ kubectl describe pod api-server-6d4f8b7c95-7wnqm
...
Events:
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
  Normal   Scheduled  5m                 default-scheduler  Successfully assigned default/api-server-6d4f8b7c95-7wnqm to node1
  Normal   Pulled     3m (x4 over 5m)    kubelet            Container image "myregistry/api-server:1.0.0" already present on machine
  Normal   Created    3m (x4 over 5m)    kubelet            Created container api-server
  Normal   Started    3m (x4 over 5m)    kubelet            Started container api-server
  Warning  BackOff    45s (x12 over 4m)  kubelet            Back-off restarting failed container

# Check the logs
$ kubectl logs api-server-6d4f8b7c95-7wnqm --previous
Error: Cannot find module 'express'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)
    at Module._load (node:internal/modules/cjs/loader:920:27)

# Shell into a running pod for interactive debugging
$ kubectl exec -it api-server-6d4f8b7c95-2xkrp -- /bin/sh
/app $ ls node_modules/
/app $ env | grep DB
DB_HOST=postgres
DB_PORT=5432

Horizontal Pod Autoscaler Basics

The Horizontal Pod Autoscaler (HPA) automatically scales your Deployment based on observed CPU utilization or custom metrics.

# hpa.yaml
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
  name: api-server-hpa
spec:
  scaleTargetRef:
    apiVersion: apps/v1
    kind: Deployment
    name: api-server
  minReplicas: 2
  maxReplicas: 10
  metrics:
    - type: Resource
      resource:
        name: cpu
        target:
          type: Utilization
          averageUtilization: 70

This tells Kubernetes: keep CPU utilization across all api-server pods at roughly 70%. If it climbs above that, add more replicas (up to 10). If it drops, scale back down (to a minimum of 2).

$ kubectl apply -f hpa.yaml
horizontalpodautoscaler.autoscaling/api-server-hpa created

$ kubectl get hpa
NAME             REFERENCE               TARGETS   MINPODS   MAXPODS   REPLICAS   AGE
api-server-hpa   Deployment/api-server   23%/70%   2         10        3          2m

The HPA requires the metrics server to be installed in your cluster. Docker Desktop and most managed Kubernetes services include it by default.

Complete Working Example

Here is a complete set of Kubernetes manifests to deploy a Node.js Express application with a PostgreSQL database. Create each file and apply them in order.

Namespace

# namespace.yaml
apiVersion: v1
kind: Namespace
metadata:
  name: user-api

PostgreSQL Secret

# postgres-secret.yaml
apiVersion: v1
kind: Secret
metadata:
  name: postgres-credentials
  namespace: user-api
type: Opaque
data:
  POSTGRES_USER: YXBwdXNlcg==           # appuser
  POSTGRES_PASSWORD: czNjdXJlUEBzczB3cmQ=  # s3cureP@ss0wrd
  POSTGRES_DB: dXNlcmRi                 # userdb

Application ConfigMap

# app-configmap.yaml
apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
  namespace: user-api
data:
  PORT: "3000"
  LOG_LEVEL: "info"
  DB_HOST: "postgres"
  DB_PORT: "5432"
  DB_NAME: "userdb"

PostgreSQL PersistentVolumeClaim

# postgres-pvc.yaml
apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
  namespace: user-api
spec:
  accessModes:
    - ReadWriteOnce
  resources:
    requests:
      storage: 5Gi

PostgreSQL Deployment

# postgres-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: postgres
  namespace: user-api
spec:
  replicas: 1
  selector:
    matchLabels:
      app: postgres
  template:
    metadata:
      labels:
        app: postgres
    spec:
      containers:
        - name: postgres
          image: postgres:16-alpine
          ports:
            - containerPort: 5432
          envFrom:
            - secretRef:
                name: postgres-credentials
          volumeMounts:
            - name: postgres-storage
              mountPath: /var/lib/postgresql/data
              subPath: pgdata
          resources:
            requests:
              cpu: "250m"
              memory: "256Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:
            exec:
              command:
                - pg_isready
                - -U
                - appuser
                - -d
                - userdb
            initialDelaySeconds: 30
            periodSeconds: 10
          readinessProbe:
            exec:
              command:
                - pg_isready
                - -U
                - appuser
                - -d
                - userdb
            initialDelaySeconds: 5
            periodSeconds: 5
      volumes:
        - name: postgres-storage
          persistentVolumeClaim:
            claimName: postgres-data

PostgreSQL Service

# postgres-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: postgres
  namespace: user-api
spec:
  selector:
    app: postgres
  ports:
    - port: 5432
      targetPort: 5432
  type: ClusterIP

Application Deployment

# api-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: user-api
  namespace: user-api
spec:
  replicas: 3
  selector:
    matchLabels:
      app: user-api
  strategy:
    type: RollingUpdate
    rollingUpdate:
      maxSurge: 1
      maxUnavailable: 0
  template:
    metadata:
      labels:
        app: user-api
    spec:
      containers:
        - name: user-api
          image: myregistry/user-api:1.0.0
          ports:
            - containerPort: 3000
          envFrom:
            - configMapRef:
                name: api-config
          env:
            - name: DB_USER
              valueFrom:
                secretKeyRef:
                  name: postgres-credentials
                  key: POSTGRES_USER
            - name: DB_PASSWORD
              valueFrom:
                secretKeyRef:
                  name: postgres-credentials
                  key: POSTGRES_PASSWORD
          resources:
            requests:
              cpu: "100m"
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "512Mi"
          livenessProbe:
            httpGet:
              path: /healthz
              port: 3000
            initialDelaySeconds: 10
            periodSeconds: 15
            failureThreshold: 3
          readinessProbe:
            httpGet:
              path: /readyz
              port: 3000
            initialDelaySeconds: 5
            periodSeconds: 5
            failureThreshold: 3
          startupProbe:
            httpGet:
              path: /healthz
              port: 3000
            failureThreshold: 30
            periodSeconds: 2

Application Service

# api-service.yaml
apiVersion: v1
kind: Service
metadata:
  name: user-api
  namespace: user-api
spec:
  selector:
    app: user-api
  ports:
    - port: 80
      targetPort: 3000
  type: LoadBalancer

Apply Everything

# Apply in order
$ kubectl apply -f namespace.yaml
namespace/user-api created

$ kubectl apply -f postgres-secret.yaml
secret/postgres-credentials created

$ kubectl apply -f app-configmap.yaml
configmap/api-config created

$ kubectl apply -f postgres-pvc.yaml
persistentvolumeclaim/postgres-data created

$ kubectl apply -f postgres-deployment.yaml
deployment.apps/postgres created

$ kubectl apply -f postgres-service.yaml
service/postgres created

# Wait for PostgreSQL to be ready before deploying the app
$ kubectl wait --for=condition=ready pod -l app=postgres -n user-api --timeout=120s
pod/postgres-7f9b8c6d45-xk2mj condition met

$ kubectl apply -f api-deployment.yaml
deployment.apps/user-api created

$ kubectl apply -f api-service.yaml
service/user-api created

Verify the Deployment

$ kubectl get all -n user-api
NAME                            READY   STATUS    RESTARTS   AGE
pod/postgres-7f9b8c6d45-xk2mj   1/1     Running   0          2m
pod/user-api-5c8f9d7b6-4xnrp    1/1     Running   0          45s
pod/user-api-5c8f9d7b6-8wmjk    1/1     Running   0          45s
pod/user-api-5c8f9d7b6-tn2ld    1/1     Running   0          45s

NAME               TYPE           CLUSTER-IP      EXTERNAL-IP   PORT(S)        AGE
service/postgres   ClusterIP      10.96.142.37    <none>        5432/TCP       2m
service/user-api   LoadBalancer   10.96.208.115   localhost     80:31452/TCP   45s

NAME                       READY   UP-TO-DATE   AVAILABLE   AGE
deployment.apps/postgres   1/1     1            1           2m
deployment.apps/user-api   3/3     3            3           45s

# Test the application
$ curl http://localhost/users
[{"id":1,"name":"Alice"},{"id":2,"name":"Bob"}]

Common Issues and Troubleshooting

1. ImagePullBackOff

Your cluster cannot pull the container image. This is the single most common issue for beginners.

$ kubectl get pods
NAME                          READY   STATUS             RESTARTS   AGE
api-server-6d4f8b7c95-2xkrp   0/1     ImagePullBackOff   0          2m

$ kubectl describe pod api-server-6d4f8b7c95-2xkrp
...
Events:
  Type     Reason     Age                From               Message
  ----     ------     ----               ----               -------
  Normal   Pulling    45s (x3 over 2m)   kubelet            Pulling image "myregistry/api-server:1.0.0"
  Warning  Failed     44s (x3 over 2m)   kubelet            Failed to pull image "myregistry/api-server:1.0.0": rpc error: code = Unknown desc = Error response from daemon: pull access denied for myregistry/api-server, repository does not exist or may require 'docker login'
  Warning  Failed     44s (x3 over 2m)   kubelet            Error: ImagePullBackOff

Fix: Verify the image name and tag are correct. If using a private registry, create an imagePullSecret and reference it in your Deployment spec. For local development with minikube, run eval $(minikube docker-env) first and then build your image so minikube can access it.

2. CrashLoopBackOff

The container starts, crashes, restarts, crashes again, and Kubernetes backs off the restart interval.

$ kubectl get pods
NAME                          READY   STATUS             RESTARTS      AGE
api-server-6d4f8b7c95-7wnqm   0/1     CrashLoopBackOff   5 (32s ago)   4m

$ kubectl logs api-server-6d4f8b7c95-7wnqm --previous
/app/server.js:3
var { Pool } = require("pg");
                ^
Error: Cannot find module 'pg'
    at Module._resolveFilename (node:internal/modules/cjs/loader:1075:15)

Fix: Check your logs with kubectl logs <pod> --previous (the --previous flag is key since the current container has no logs yet). Common causes: missing dependencies in the Docker image, incorrect CMD in Dockerfile, environment variables not set, or the application code throwing an unhandled exception on startup.

3. Pod Stuck in Pending State

The scheduler cannot find a node with enough resources to place the Pod.

$ kubectl get pods
NAME                          READY   STATUS    RESTARTS   AGE
api-server-6d4f8b7c95-9xklp   0/1     Pending   0          5m

$ kubectl describe pod api-server-6d4f8b7c95-9xklp
...
Events:
  Type     Reason            Age   From               Message
  ----     ------            ----  ----               -------
  Warning  FailedScheduling  5m    default-scheduler   0/3 nodes are available: 3 Insufficient cpu. preemption: 0/3 nodes are available: 3 No preemption victims found for incoming pod.

Fix: Either reduce the resource requests in your Deployment manifest, or add more nodes to your cluster. Run kubectl describe nodes to see current resource allocation across the cluster.

4. Service Not Routing Traffic

Your Service exists but requests are not reaching your Pods.

$ kubectl get endpoints user-api -n user-api
NAME       ENDPOINTS   AGE
user-api   <none>      3m

No endpoints means the Service's selector does not match any running Pod labels. Fix: Compare the Service's spec.selector with your Pod's metadata.labels. They must match exactly. Also verify your Pods are in a Ready state — if the readiness probe is failing, the Pod will not be added to the Service's endpoints.

# Check if labels match
$ kubectl get pods -n user-api --show-labels
NAME                        READY   STATUS    RESTARTS   AGE   LABELS
user-api-5c8f9d7b6-4xnrp   1/1     Running   0          5m    app=user-api

$ kubectl get service user-api -n user-api -o jsonpath='{.spec.selector}'
{"app":"user-api"}

5. OOMKilled — Out of Memory

Your container exceeded its memory limit and was killed by the kernel.

$ kubectl get pods
NAME                          READY   STATUS      RESTARTS      AGE
api-server-6d4f8b7c95-2xkrp   0/1     OOMKilled   3 (15s ago)   2m

$ kubectl describe pod api-server-6d4f8b7c95-2xkrp
...
    Last State:     Terminated
      Reason:       OOMKilled
      Exit Code:    137

Fix: Increase the memory limit in your resource spec, or fix the memory leak in your application. For Node.js, you may also need to set the --max-old-space-size flag to match your container's memory limit.

Best Practices

  • Always set resource requests and limits. Without them, a single Pod can consume all resources on a node and starve other workloads. Start conservative, monitor with kubectl top, and adjust.

  • Use readiness and liveness probes on every Deployment. Without probes, Kubernetes sends traffic to containers that are still starting up or have silently failed. A misconfigured probe is worse than no probe, though — make sure your liveness endpoint genuinely checks application health, not downstream dependencies.

  • Never use the latest tag in production. Always pin to a specific image version (e.g., myapp:1.2.3). Using latest makes rollbacks impossible and means you have no idea what version is actually running.

  • Use namespaces to isolate environments. Run dev, staging, and production in separate namespaces with different resource quotas and access controls. This prevents a runaway dev workload from affecting production.

  • Store secrets in a proper secrets manager. Kubernetes Secrets are base64-encoded, not encrypted. For production, integrate with HashiCorp Vault, AWS Secrets Manager, or use Sealed Secrets. Never commit Secret YAML files with real credentials to version control.

  • Use maxUnavailable: 0 in your rolling update strategy. This ensures at least as many replicas as you requested are always running during a deployment. Combined with readiness probes, this gives you true zero-downtime deployments.

  • Implement graceful shutdown in your application. When Kubernetes sends SIGTERM to your container, you have 30 seconds (by default) to finish in-flight requests and shut down cleanly. Handle this in your Node.js code:

var server = app.listen(port, function() {
  console.log("Server started on port " + port);
});

process.on("SIGTERM", function() {
  console.log("SIGTERM received, shutting down gracefully");
  server.close(function() {
    console.log("HTTP server closed");
    process.exit(0);
  });
});
  • Use labels consistently. Labels are how Kubernetes connects Deployments to Pods, and Services to Pods. Adopt a consistent labeling scheme: app, version, environment, team. This makes querying and managing resources much easier.

  • Run one process per container. Do not try to run your app and a sidecar process in the same container. If you need multiple processes, use multiple containers in the same Pod — they share networking and can communicate over localhost.

  • Set up horizontal pod autoscaling from day one. Even if you start with a fixed replica count, configuring an HPA is straightforward and can save you from outages during unexpected traffic spikes.

References

Powered by Contentful