
Introduction
You probably don't need Kubernetes. If your app runs on one server and handles your traffic fine, stop reading. Come back when it doesn't.
Still here? Good. Your Docker Compose setup works until a server dies at 2 AM and you spend the weekend recovering containers by hand. K8s prevents that specific weekend. It runs your containers across multiple machines, keeps them healthy, routes traffic, and replaces anything that crashes. Originally Google's, now maintained by the CNCF. Overkill for a side project. Not overkill when you are running multiple services that need to stay up without someone babysitting them.
Core Concepts: Pods, Nodes and Clusters
A cluster is the set of machines running under a Kubernetes control plane. The control plane schedules workloads, monitors health, and converges actual state toward declared state. A node is one machine in that cluster — control plane nodes handle management, worker nodes run your containers. On EKS, GKE, or AKS, the cloud provider manages the control plane. You manage workers. A pod is the smallest deployable unit: one or more containers sharing a network namespace and storage. Most pods hold one container. Multi-container pods are for sidecars.
apiVersion: v1
kind: Pod
metadata:
name: my-nginx
labels:
app: nginx
environment: dev
spec:
containers:
- name: nginx
image: nginx:1.30-alpine
ports:
- containerPort: 80Labels are key-value pairs that wire everything together. Services find pods by labels. Deployments manage pods by labels. Ignore labels and nothing connects to anything.
Copy-paste these. Seriously.
# Create the pod from your YAML file
kubectl apply -f pod.yaml
# Check that the pod is running
kubectl get pods
# See detailed information about the pod
kubectl describe pod my-nginx
You will almost never create standalone pods in production. They are disposable. If the container crashes, the kubelet restarts it in place according to the pod's restartPolicy (default Always). But if the node fails or the pod is deleted or evicted, nothing recreates it on another node. A Deployment does that for you.
Deployments and ReplicaSets
The resource you actually interact with day to day. A Deployment tells Kubernetes: run this many copies of this container, keep them healthy, and roll out updates without downtime. Under the hood it manages a ReplicaSet, which manages pods. You never touch ReplicaSets directly.
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-api
labels:
app: node-api
spec:
replicas: 3
strategy:
type: RollingUpdate
rollingUpdate:
maxUnavailable: 1
maxSurge: 1
selector:
matchLabels:
app: node-api
template:
metadata:
labels:
app: node-api
spec:
containers:
- name: api
image: myregistry/node-api:2.1.0
ports:
- containerPort: 3000
resources:
requests:
cpu: 250m
memory: 256Mi
limits:
memory: 512Mi
readinessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /health
port: 3000
initialDelaySeconds: 15
periodSeconds: 20replicas: 3 means three instances running at all times. Crash? Replacement starts immediately. But this is also K8s hiding your bugs. A memory leak that OOMKills a container every few hours? Kubernetes restarts it so fast that users never notice. The underlying bug goes undiagnosed for months. Be aware of that tradeoff.
selector.matchLabels tells the Deployment which pods it owns. Labels between selector and template must match or Kubernetes rejects the manifest.
Readiness and liveness probes. Non-negotiable in production. Readiness gates when a pod can receive traffic — without it, rolling updates send requests to containers that haven't finished booting. Liveness tells Kubernetes when a pod is stuck and should be killed. Without probes, K8s has no way to tell if your app is healthy or silently broken.
Rolling Updates
Change the image tag. Apply the manifest. Kubernetes gradually swaps old pods for new. Zero downtime. maxUnavailable controls how many pods can be down at once. maxSurge controls how many extra pods can spin up. The manifest above sets maxUnavailable: 1 and maxSurge: 1, so with three replicas at least two pods serve traffic and at most four exist during a rollout. The resources block is explained in the scaling section below; the HPA depends on those requests.
Deploy goes wrong? Roll back:
# Check the rollout history
kubectl rollout history deployment/node-api
# Roll back to the previous version
kubectl rollout undo deployment/node-api
# Roll back to a specific revision
kubectl rollout undo deployment/node-api --to-revision=2
# Watch the rollout in real time
kubectl rollout status deployment/node-api
Built in. No additional tooling. This alone justifies the complexity over manual Docker Compose deploys.
Services and Networking
Pods get IP addresses but those addresses are disposable. Pod restarts? New IP. Hard-code a pod IP and things break immediately.
A Service gives you a stable virtual IP and DNS name that routes traffic to pods matched by label selectors. Three types: ClusterIP is internal-only — service-to-service communication, like your API talking to your database. That is what ClusterIP actually means: reachable inside the cluster, invisible outside it. NodePort exposes the app on a static port on every node — fine for development, not production. LoadBalancer provisions a real cloud load balancer. That is how you expose services to the internet.
apiVersion: v1
kind: Service
metadata:
name: node-api-service
spec:
type: ClusterIP
selector:
app: node-api
ports:
- protocol: TCP
port: 80
targetPort: 3000selector matches pod labels from the Deployment. port is what the Service listens on. targetPort is the container port. Other pods reach the API at http://node-api-service:80 via Kubernetes DNS.
No IP addresses to track. Reference services by name. One of the cleaner parts of the whole system, honestly.
ConfigMaps and Secrets
Do not bake config into images. Dev, staging, production — different database URLs, different feature flags, different API keys. ConfigMaps hold non-sensitive data. Secrets hold sensitive data (poorly, but we will get to that).
ConfigMaps
Key-value stores injected into pods as env vars or mounted as files:
apiVersion: v1
kind: ConfigMap
metadata:
name: api-config
data:
NODE_ENV: "production"
LOG_LEVEL: "info"
MAX_CONNECTIONS: "100"
CACHE_TTL: "3600"
---
apiVersion: v1
kind: Secret
metadata:
name: api-secrets
type: Opaque
data:
# Values must be base64-encoded
DATABASE_URL: cG9zdGdyZXM6Ly91c2VyOnBhc3NAZGItaG9zdDo1NDMyL215ZGI=
API_KEY: c3VwZXItc2VjcmV0LWFwaS1rZXktMTIzNDU=Important: Kubernetes Secrets are base64-encoded, not encrypted. Anyone with cluster access can decode them. For real encryption at rest, enable EncryptionConfiguration on the API server or use something like HashiCorp Vault, AWS Secrets Manager, or the Sealed Secrets controller.
Inject via environment variables or volume mounts. Env vars for simple key-value pairs. Volumes for config files. Change a ConfigMap without rebuilding the image — mounted volumes update automatically. Env vars need a pod restart, but kubectl rollout restart deployment/node-api takes seconds.
Ingress and External Access
Ten microservices. Ten LoadBalancer services. Ten cloud load balancers, each with its own bill and public IP. Terrible.
Ingress gives you one entry point. Routing by hostname or URL path. An Ingress Controller (Traefik, HAProxy, F5 NGINX Ingress Controller, AWS Load Balancer Controller and others) implements the actual routing rules. The community ingress-nginx controller was retired in March 2026 and no longer receives releases or security fixes, so if you still run it, plan a migration. Set ingressClassName below to the class your maintained controller registers.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: app-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
ingressClassName: traefik # your controller's IngressClass
tls:
- hosts:
- api.myapp.com
- app.myapp.com
secretName: myapp-tls
rules:
- host: api.myapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: node-api-service
port:
number: 80
- host: app.myapp.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: frontend-service
port:
number: 80One Ingress. Two services. Traffic splits by hostname. api.myapp.com hits the API. app.myapp.com hits the frontend.
The tls section handles HTTPS. Pair with cert-manager and you get free Let's Encrypt certificates that auto-renew. Path-based routing within a single host works too — /api/* to one service, /docs/* to another. For many apps, host- and path-based routing like this is all the ingress configuration you need.
Gateway API: the Successor to Ingress
For new clusters, the Kubernetes project points to Gateway API. A platform team defines a Gateway (listeners and TLS), and each app team attaches an HTTPRoute. Implementations include Envoy Gateway, Istio, Cilium, Traefik and NGINX Gateway Fabric, and the ingress2gateway tool converts existing Ingress manifests. The API host route from above looks like this:
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: api-route
spec:
parentRefs:
- name: prod-gateway # Gateway owned by your platform team
hostnames:
- api.myapp.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: node-api-service
port: 80Scaling and Resource Management
Autoscaling only works if you tell Kubernetes how much CPU and memory your containers need. Skip resource definitions and the scheduler is guessing.
Resource Requests and Limits
Request = minimum guaranteed. Limit = maximum allowed. Exceed memory limit? Container killed. Exceed CPU limit? Throttled.
Nobody gets these right on the first try. Requests too high, you waste cluster capacity. Limits too low, containers die under normal load. A memory limit set just below a service's peak usage causes intermittent OOMKills that are easy to misdiagnose. Deploy generous first. Watch real usage. Then tighten. Starting too tight is always worse than starting too loose.
Horizontal Pod Autoscaler (HPA)
Watches metrics. Adjusts replica count.
apiVersion: autoscaling/v2
kind: HorizontalPodAutoscaler
metadata:
name: node-api-hpa
spec:
scaleTargetRef:
apiVersion: apps/v1
kind: Deployment
name: node-api
minReplicas: 2
maxReplicas: 10
metrics:
- type: Resource
resource:
name: cpu
target:
type: Utilization
averageUtilization: 70
- type: Resource
resource:
name: memory
target:
type: Utilization
averageUtilization: 80
behavior:
scaleDown:
stabilizationWindowSeconds: 300
policies:
- type: Percent
value: 25
periodSeconds: 60
Between 2 and 10 replicas. CPU above 70% or memory above 80% triggers scale-out. The behavior section prevents flapping — the autoscaler bouncing wildly between up and down. Stabilization window waits 5 minutes. Scale-down capped at 25% per minute.
Prerequisites: Metrics Server installed and resource requests defined on your containers. Some managed offerings ship it by default, but on EKS it is not deployed by default; install it, for example, as the Metrics Server community add-on. The Deployment above defines requests, so kubectl get hpa shows real percentages instead of <unknown>. The HPA calculates utilization relative to those requests. No requests? The HPA has nothing to work with.
Debugging Running Applications
This is the section that actually matters for your day-to-day. Containers crash. Pods refuse to start. Services go dark. Knowing how to figure out why is the difference between a 5-minute fix and a 2-hour panic.
kubectl logs
First stop. Always.
# View logs for a specific pod
kubectl logs node-api-7d4b8c6f9-x2k4m
# Stream logs in real time (like tail -f)
kubectl logs -f node-api-7d4b8c6f9-x2k4m
# View logs from a previous crashed container
kubectl logs node-api-7d4b8c6f9-x2k4m --previous
# View logs from all pods matching a label
kubectl logs -l app=node-api --all-containers=true
# Show only the last 100 lines
kubectl logs node-api-7d4b8c6f9-x2k4m --tail=100
# Show logs from the last 30 minutes
kubectl logs node-api-7d4b8c6f9-x2k4m --since=30m
--previous is the one that saves you. Container in CrashLoopBackOff? That flag shows logs from the last run before the crash. The actual error message. Usually that is all you need.
kubectl describe
When logs aren't enough. Shows events, conditions, configuration for any resource. A pod that won't start at all won't have logs. But kubectl describe will show you exactly why in the Events section at the bottom. "Image not found." "Insufficient CPU." "Failed to mount volume." The answer is almost always there.
kubectl exec
Get inside a running container. Check files, test connectivity, run diagnostics.
kubectl exec -it pod-name -- /bin/sh for a shell. kubectl exec pod-name -- cat /etc/config/app.json for a single command. Add -c container-name for multi-container pods.
kubectl port-forward
Access a pod or service from your machine without exposing it. kubectl port-forward service/node-api-service 8080:80, then http://localhost:8080. Better than creating throwaway NodePort services for every debugging session.
Common Debugging Workflow
When something breaks:
kubectl get pods— look for CrashLoopBackOff, ImagePullBackOff, or Pending.kubectl describe pod <pod-name>on anything unhealthy. Check the Events section.- Pod started but misbehaving?
kubectl logs <pod-name>. - Pod crashed?
kubectl logs <pod-name> --previous. - Need to investigate further?
kubectl exec -it <pod-name> -- /bin/sh. - Networking issue?
kubectl port-forwardto test connectivity, and check that Service selectors match pod labels.
Image pull errors. Config mistakes. Resource limits. Selector mismatches. Those cover a large share of everyday Kubernetes problems, and this workflow catches each of them.
Conclusion
Start with a managed cluster, learn kubectl logs and kubectl describe, and resist the urge to write Helm charts until your third project.