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
kubectlCLI 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 instancesselector.matchLabels— how the Deployment finds its Podstemplate— 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
latesttag in production. Always pin to a specific image version (e.g.,myapp:1.2.3). Usinglatestmakes 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: 0in 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.
