Most Kubernetes tutorials hand you a YAML file and tell you to run kubectl apply. It works, something starts, and you learn nothing about why. The concepts that actually make Kubernetes make sense — reconciliation, labels, the difference between a request and a limit — get skipped because they aren't a command you can copy.
This guide goes the other way. It starts with the one idea the whole system is built on, then introduces objects only as you need them.
What Kubernetes Actually Does
Kubernetes (abbreviated K8s — eight letters between the K and the s) is an open-source container orchestration platform. It originated at Google and is now maintained by the Cloud Native Computing Foundation.
That description is accurate and almost useless. Here's the version that helps.
Kubernetes is a declarative reconciliation system. You don't tell it what to do; you tell it what you want to be true. "I want three copies of this container running." Kubernetes writes that down, then runs a control loop forever:
- Read the desired state (what you declared).
- Observe the actual state (what's running).
- If they differ, take an action to close the gap.
- Repeat.
Every meaningful behaviour people attribute to Kubernetes falls out of this loop. Kill a pod and a new one appears — not because something detected a failure and responded, but because actual state stopped matching desired state and the loop did what it always does. A node dies and its workloads reappear elsewhere for the same reason. A rolling update is just the loop being fed a new desired state and walking toward it in increments.
Kubernetes has no "restart the thing" feature. It has a loop that continuously drives actual state toward declared state, and self-healing is what that looks like from the outside. Once this clicks, the API stops feeling arbitrary — every object is a description of desired state plus a controller trying to satisfy it.
This is also why kubectl apply is preferred over imperative commands like kubectl run. Applying a manifest updates the declared state. Imperative commands mutate the cluster directly and leave nothing behind that describes what you intended.
Do You Actually Need Kubernetes?
Worth asking before you invest weeks. Kubernetes solves real problems, but it charges rent whether or not you have those problems.
You probably don't need it if:
- You run one or two services and deploy a few times a week. A managed platform — Vercel, Netlify, Railway, a cloud provider's container service — will do the job with a fraction of the concepts.
- Nobody on the team has operational capacity to own it. A cluster is infrastructure you now maintain: upgrades, node pools, ingress, certificates, RBAC.
- Your bottleneck is product, not deployment. Kubernetes doesn't ship features.
It starts paying for itself when:
- You have many services that need a consistent deployment and networking model.
- You need bin-packing across a fleet — many workloads sharing pooled machines efficiently.
- Multiple teams need self-service deploys against shared infrastructure with real isolation.
- You need portability across environments, or you're already committed to the CNCF ecosystem.
EKS, GKE, and AKS manage the control plane — the API server and etcd stop being your problem. Your workloads, networking, ingress, RBAC, resource tuning, and upgrade cadence remain entirely yours. "Managed Kubernetes" is a meaningfully smaller commitment than self-hosted, but it is not a small one.
If you're still here, the rest of this is worth your time.
The Objects You Need on Day One
Kubernetes has dozens of object types. You need five to deploy something real.
Pods
A Pod is the smallest deployable unit — one or more containers that are scheduled together and share a network namespace and storage volumes.
Sharing a network namespace has a concrete consequence: containers in the same pod reach each other on localhost, and they cannot both bind the same port. This is what sidecars are built on — a log shipper or proxy alongside your app, talking to it over loopback.
Most pods have exactly one container. Reach for a second only when the two processes genuinely must share a lifecycle and a network namespace.
You will almost never create a pod directly. A bare pod is not managed by anything — if it dies, it stays dead. Nothing is reconciling it. You create Deployments, which create pods for you.
Deployments
A Deployment declares desired state for a set of identical pods: which image, how many replicas, what config. It manages ReplicaSets under the hood, and a rolling update works by creating a new ReplicaSet and shifting replicas from old to new incrementally.
This is also what makes rollback cheap. The old ReplicaSet is still there, scaled to zero, so kubectl rollout undo reverses direction rather than redeploying from scratch.
Services
Pods are ephemeral. They get replaced, rescheduled, and reassigned IPs constantly. Anything holding a pod IP is holding a value with an expiry date it can't see.
A Service is a stable virtual IP and DNS name in front of a set of pods, chosen by label selector. Hit the Service; it routes to whichever pods currently exist and are ready.
The types you'll meet:
| Type | What it does | When to use |
|------|--------------|-------------|
| ClusterIP | Internal-only virtual IP (the default) | Service-to-service traffic inside the cluster |
| NodePort | Opens a port on every node | Mostly a building block; rarely used directly |
| LoadBalancer | Provisions a cloud load balancer | Exposing a service externally on a managed cloud |
| ExternalName | Maps to an external DNS name | Pointing at something outside the cluster |
For HTTP, you usually want an Ingress instead: one load balancer, routing many hostnames and paths to many services. Ingress objects do nothing on their own — they're inert config until an ingress controller (ingress-nginx, Traefik, or a cloud-native one) is installed to act on them. This trips up nearly everyone once.
ConfigMaps and Secrets
Config that varies by environment doesn't belong in your image. ConfigMaps hold non-sensitive key/value config; Secrets hold sensitive values and are the same idea with different handling.
Base64 is an encoding, not a cipher. By default, Secret data is stored in etcd unencrypted and anyone with read access to Secrets in a namespace can decode it trivially. Encryption at rest is a cluster-level configuration you have to enable, and RBAC is what actually restricts access. Teams handling genuinely sensitive material typically integrate an external secret manager such as HashiCorp Vault rather than relying on Secrets alone.
Namespaces
Namespaces partition a cluster into virtual sub-clusters, scope names, and give you a handle for quotas and access policy.
They are not a security boundary by themselves. By default, a pod in one namespace can talk to a pod in another over the network. Isolation requires NetworkPolicies for traffic and RBAC for API access. Namespaces organize; they don't protect.
Your First Deployment
Here's an nginx Deployment written the way you'd actually want it in a cluster — not the two-field version tutorials usually show.
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
labels:
app: nginx
spec:
replicas: 3
selector:
matchLabels:
app: nginx
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1 # one extra pod allowed during the update
maxUnavailable: 0 # never drop below the desired replica count
template:
metadata:
labels:
app: nginx # must match spec.selector.matchLabels
spec:
containers:
- name: nginx
image: nginx:1.27.3 # pin a real version; never use :latest
ports:
- containerPort: 80
resources:
requests:
memory: "64Mi"
cpu: "100m"
limits:
memory: "128Mi"
readinessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 3
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 15
securityContext:
runAsNonRoot: true
allowPrivilegeEscalation: false
readOnlyRootFilesystem: true
Apply it and watch the rollout:
kubectl apply -f nginx-deployment.yaml
kubectl rollout status deployment/nginx-deployment
kubectl get pods -l app=nginx
Then give it a stable address:
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: ClusterIP
selector:
app: nginx # matches pods by label, not by Deployment name
ports:
- port: 80 # the port the Service listens on
targetPort: 80 # the port on the pod
Any pod in the cluster can now reach it at nginx-service, or nginx-service.default.svc.cluster.local in full. From your laptop:
kubectl port-forward service/nginx-service 8080:80
The single most common beginner bug: a Service whose selector doesn't match any pod's labels. Kubernetes won't error — the selector matched zero pods, which is a valid answer. You get a Service with no endpoints and connections that hang or refuse. When something is unreachable, run kubectl get endpoints my-service first. Empty output means your labels don't line up.
Probes: The Part Everyone Gets Wrong
Three probe types, doing three different jobs. Confusing them causes outages.
readinessProbe— "can this pod serve traffic right now?" On failure, the pod is removed from the Service's endpoints but keeps running. This is your traffic gate.livenessProbe— "is this container wedged?" On failure, the kubelet kills and restarts the container. This is a last resort for deadlocks.startupProbe— "has this thing finished booting?" Liveness and readiness are suspended until it passes. Use it for slow-starting apps rather than inflating liveness timeouts.
The classic failure: pointing a liveness probe at an endpoint that checks the database. The database gets slow, every liveness probe fails at once, Kubernetes restarts every pod simultaneously, and the restart storm makes the incident considerably worse. Nothing was wrong with the containers.
Rule of thumb: readiness probes may check dependencies. Liveness probes should check only whether this process is functioning. A liveness probe that fails should mean "restarting this container will help" — if that isn't true, it shouldn't be a liveness probe.
Requests and Limits
Two numbers, two entirely different mechanisms.
Requests are for the scheduler. A pod requesting 100m CPU is placed on a node with 100m unreserved. Requests are a reservation, and the scheduler only ever looks at them — not actual usage.
Limits are enforced at runtime, and CPU and memory behave differently:
- CPU is compressible. Exceed the limit and the container is throttled — slowed, not killed.
- Memory is not. Exceed the limit and the container is OOMKilled. No warning, no grace period.
That asymmetry drives a widely-used guideline: always set memory limits, and be cautious with CPU limits. A throttled container that could have used idle CPU is a self-inflicted latency problem, whereas unbounded memory growth threatens the whole node.
Requests and limits also determine a pod's QoS class, which decides who gets evicted when a node runs out of memory:
- Guaranteed — requests equal limits for every container. Evicted last.
- Burstable — requests set, lower than limits. Evicted in the middle.
- BestEffort — nothing set at all. Evicted first.
Leaving resources unset doesn't mean "no constraints." It means BestEffort — first in line to die under pressure, and invisible to the scheduler, which will happily overcommit the node.
Debugging: The Commands That Matter
Most Kubernetes debugging is four commands.
# What's the cluster's own story? Start here. Almost always here.
kubectl describe pod my-pod
# What did the app say?
kubectl logs my-pod
kubectl logs my-pod --previous # logs from the container that just crashed
kubectl logs -l app=nginx --tail=50 # across all matching pods
# Recent cluster events, newest last
kubectl get events --sort-by=.metadata.creationTimestamp
# Get inside
kubectl exec -it my-pod -- /bin/sh
kubectl describe pod is the one to reach for reflexively. The Events section at the bottom explains most failures in plain language — image pull errors, failed scheduling, probe failures, OOM kills.
Reading the common pod states:
Pending— not scheduled. Usually no node has enough unreserved capacity for your requests, or a volume can't be attached.describenames the reason.ImagePullBackOff— wrong image name or tag, or missing registry credentials.CrashLoopBackOff— the container starts and exits repeatedly. Kubernetes backs off between restarts. This is a symptom, not a cause;kubectl logs --previousshows what the dying container said.OOMKilled— hit the memory limit. Either the limit is too low or the app is leaking.Runningbut notReady— the readiness probe is failing. The container is alive; it's saying it can't serve.
And when a deploy goes wrong:
kubectl rollout undo deployment/nginx-deployment
kubectl rollout history deployment/nginx-deployment
Practices Worth Adopting Early
- Pin image tags.
nginx:latestmeans different things on different days, which makes rollbacks meaningless and "works on my node" a real phenomenon. Pin a version, or better, a digest. - Set resource requests on everything. Otherwise you're BestEffort and the scheduler is flying blind.
- Use
maxUnavailable: 0for user-facing services so a rollout never dips below the replica count. - Label consistently. Labels are how Services, selectors, and your own tooling find things. The
app.kubernetes.io/*labels from the recommended-labels convention are a reasonable default. - Add PodDisruptionBudgets before your first node drain, not after. A PDB tells Kubernetes the minimum availability to preserve during voluntary disruptions like upgrades.
- Keep manifests in Git. Declarative state belongs in version control. This is the premise GitOps tooling like Argo CD and Flux builds on, and it's worth adopting even without them.
- Don't run as root.
runAsNonRoot,allowPrivilegeEscalation: false, and a read-only root filesystem cost nothing on a greenfield workload and are painful to retrofit later.
Where to Go Next
Once deployments and services feel routine:
- Helm — templating and packaging for manifests. The moment you're maintaining near-identical YAML for staging and production, you want Helm or Kustomize.
- Horizontal Pod Autoscaler — scales replicas on CPU, memory, or custom metrics. Requires resource requests to be set, which is one more reason to set them.
- NetworkPolicies — default-deny traffic rules. Note that they require a CNI plugin that implements them; on a cluster whose CNI ignores them, a NetworkPolicy silently does nothing.
- Prometheus and Grafana — the conventional open-source monitoring pair for clusters. Datadog is a common commercial alternative if you'd rather buy than run.
- StatefulSets and operators — for databases and other stateful workloads, which have genuinely different requirements from stateless apps.
Getting a Cluster to Practice On
Don't learn on a cloud cluster. Run one locally, break it freely, delete it, start again:
# kind — Kubernetes in Docker, fast and disposable
brew install kind
kind create cluster --name learning
kubectl cluster-info --context kind-learning
kubectl apply -f nginx-deployment.yaml
# Wipe it and start over whenever you like
kind delete cluster --name learning
minikube and k3d are equally good starting points, and Docker Desktop ships a single-node cluster you can enable in settings. Any of them will teach you the same lessons for free.
The thing to internalize: you're not learning commands. You're learning to describe a desired state precisely enough that a control loop can reach it — and to read the cluster's explanation when it can't.
Resources
- Kubernetes Documentation — the concepts section is genuinely well written
- kubectl Cheat Sheet
- Configuration Best Practices
- Pod Lifecycle — probes and states in detail
- Recommended Labels