One Container vs. Ten Thousand
Docker runs one container. Kubernetes runs thousands and keeps them alive. That’s the gap we’re bridging today, and honestly, it’s a bigger leap than most tutorials let on.
See, I remember the first time I tried to move beyond a single docker run command. Mid-2024, maybe late June. Our team had a Node.js API wrapped in a Docker image, running happily on a single EC2 instance. Then traffic spiked during a product launch and the whole thing fell over. No auto-restart, no scaling, no fallback. Just a dead container and a Slack channel on fire.
Someone on the team said “we should probably look into K8s” and I remember thinking — yeah, I’ve been dodging that for months. Kubernetes felt like one of those technologies that everyone name-drops but nobody actually explains well. Lots of jargon. Lots of YAML. Lots of architecture diagrams with arrows going everywhere.
But here’s what I’ve learned since then: once you get past the initial vocabulary dump, container orchestration with Kubernetes follows a pretty logical pattern. You describe what you want. K8s figures out how to make it happen. Everything else is details.
So let’s do something practical. We’re going from zero to a running application on a Kubernetes cluster, and I’ll walk through every core concept as we hit it. No skipping ahead, no “just trust me on this” hand-waving.
Getting kubectl and a Cluster Running
Before anything else, you need two things: a Kubernetes cluster and the kubectl command-line tool to talk to it. For messing around locally, Minikube or Kind (Kubernetes in Docker — yes, it’s Kubernetes running inside Docker containers, which is a bit recursive but works great) are your best bets.
Here’s the setup on Linux or macOS:
# Install kubectl on Linux/macOS
curl -LO "https://dl.k8s.io/release/$(curl -L -s https://dl.k8s.io/release/stable.txt)/bin/linux/amd64/kubectl"
chmod +x kubectl
sudo mv kubectl /usr/local/bin/
# Install Minikube
curl -LO https://storage.googleapis.com/minikube/releases/latest/minikube-linux-amd64
sudo install minikube-linux-amd64 /usr/local/bin/minikube
# Start a local cluster
minikube start --driver=docker
# Verify the cluster is running
kubectl cluster-info
kubectl get nodes
After running kubectl get nodes, you should see your Minikube node listed with a Ready status. If it says NotReady, give it 30 seconds — the kubelet sometimes needs a moment to phone home.
Tip: On Windows, WSL2 is probably your smoothest path. Native Windows Minikube works but you’ll hit occasional driver headaches with Hyper-V vs. Docker Desktop. Save yourself the grief and use WSL2.
Now, if you’re targeting a cloud provider — AWS EKS, Google GKE, Azure AKS — each has its own way of wiring up kubectl with the right kubeconfig. Check their docs for the specifics. But for learning? Minikube is perfect. It gives you a real, fully functional cluster on your laptop.
Pods: Where Your Code Actually Lives
Right, so here’s the first vocabulary word. A Pod is the smallest thing Kubernetes can deploy. Think of it as a thin wrapper around one or more containers that share the same network namespace and storage volumes. In practice, you’ll almost always run a single container per pod.
Let’s create one directly, just to see what happens:
# Run a pod directly (for learning purposes only)
kubectl run my-nginx --image=nginx:1.25 --port=80
# Check pod status
kubectl get pods
kubectl describe pod my-nginx
# View logs from the pod
kubectl logs my-nginx
# Execute a command inside the pod
kubectl exec -it my-nginx -- /bin/bash
# Clean up
kubectl delete pod my-nginx
Play around with that describe output for a minute. It’s dense, yeah, but it tells you everything: what node the pod landed on, what image it pulled, whether the readiness checks passed, recent events. When something breaks later (and it will), kubectl describe is usually the first place I look.
Warning: You can create pods directly like we just did, but never do this in production. Seriously. Pods are ephemeral. If a pod crashes, nothing brings it back. It just… stays dead. Which brings us to the thing that actually manages pods for you.
Deployments: Telling K8s What You Want
A Deployment is where Kubernetes starts to feel genuinely powerful. Instead of creating individual pods by hand, you write a declaration: here’s my container image, here’s how many copies I want running, here’s how to handle updates. Kubernetes reads that and continuously works to make reality match your description.
It’s declarative, right? You don’t say “spin up three pods.” You say “I want three pods running at all times.” Subtle difference, massive implications. If one dies, K8s spins up a replacement automatically. If you change the image tag, it rolls out the update with zero downtime.
Here’s a full Deployment manifest for a simple Node.js app. Save it as deployment.yaml:
apiVersion: apps/v1
kind: Deployment
metadata:
name: node-app
labels:
app: node-app
spec:
replicas: 3
selector:
matchLabels:
app: node-app
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
template:
metadata:
labels:
app: node-app
spec:
containers:
- name: node-app
image: node:20-alpine
command: ["node", "-e", "
const http = require('http');
const server = http.createServer((req, res) => {
res.writeHead(200);
res.end('Hello from Kubernetes! Pod: ' + process.env.HOSTNAME);
});
server.listen(3000);
"]
ports:
- containerPort: 3000
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
readinessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 5
periodSeconds: 10
livenessProbe:
httpGet:
path: /
port: 3000
initialDelaySeconds: 15
periodSeconds: 20
Lot of YAML, I know. Let me break down what matters here.
replicas: 3 means K8s will maintain three running copies of your pod at all times. Kill one, another pops up within seconds. strategy: RollingUpdate with maxUnavailable: 0 means during an update, Kubernetes never takes down an existing pod until a new one is verified healthy. Zero downtime deployments, baked right in.
And those probe sections? They’re arguably the most important part of the whole manifest. readinessProbe tells Kubernetes when a pod is ready to receive traffic (so it doesn’t route requests to a container that’s still booting). livenessProbe tells Kubernetes when a pod is stuck and needs to be killed and restarted. Without probes, K8s is basically flying blind.
Tip: Set your
livenessProbeinitialDelaySeconds higher than your readiness probe. If the liveness check fires before the app finishes starting, K8s will kill the pod in a restart loop forever. I’ve watched colleagues debug that for hours. Don’t be that person.
Now let’s deploy it:
# Apply the deployment
kubectl apply -f deployment.yaml
# Watch pods come up in real time
kubectl get pods -w
# Check deployment status
kubectl rollout status deployment/node-app
# Scale up or down
kubectl scale deployment/node-app --replicas=5
# View deployment details
kubectl describe deployment node-app
Run kubectl get pods -w and watch the pods go from Pending to ContainerCreating to Running. There’s something oddly satisfying about watching Kubernetes do its thing in real time. Each pod gets a unique name like node-app-7d5f8c6b4-x2k9p — that suffix is generated by the ReplicaSet that the Deployment creates behind the scenes.
Oh, and that scale command? Try it. Go from 3 replicas to 5, then back down to 2. Kubernetes handles the creation and graceful termination of pods automatically. No SSH-ing into servers, no manual process management. Just tell it the number and walk away.
Services: Giving Your App a Stable Address
Here’s a problem you’ll run into immediately. Each pod gets its own IP address, but those IPs are random and change every time a pod restarts. So how do you actually reach your application reliably?
A Service solves this by putting a stable network endpoint in front of a group of pods. It uses label selectors to figure out which pods belong to it, and it handles load balancing across them. Save this as service.yaml:
apiVersion: v1
kind: Service
metadata:
name: node-app-service
spec:
type: LoadBalancer
selector:
app: node-app
ports:
- protocol: TCP
port: 80
targetPort: 3000
Pretty short compared to the Deployment, right? But don’t let the brevity fool you — a lot happens behind that config.
# Apply the service
kubectl apply -f service.yaml
# Get the external IP (or use minikube service)
kubectl get svc node-app-service
# For Minikube, open the service in your browser
minikube service node-app-service
When you run kubectl get svc, the EXTERNAL-IP column might show <pending> for a while on Minikube. That’s because Minikube doesn’t have a real cloud load balancer. Run minikube service node-app-service and it’ll open a tunnel to the service directly. On actual cloud providers like AWS or GCP, that LoadBalancer type automatically provisions an ELB or Cloud Load Balancer for you. Pretty slick.
Quick rundown of the three service types you’ll encounter:
- ClusterIP — Internal only. Other pods inside the cluster can reach it, but nothing outside can. Default type.
- NodePort — Exposes the service on a static port on every node’s IP. Useful for quick tests, clunky for production.
- LoadBalancer — Provisions an external load balancer through your cloud provider. What you’ll usually want for internet-facing apps.
Tip: For HTTP routing with multiple services behind a single IP (like
/apigoing to one service and/webto another), look into Ingress controllers. Nginx Ingress and Traefik are the popular choices as of early 2026. Services alone won’t handle path-based routing.
ConfigMaps and Secrets: Keeping Config Out of Your Images
Hardcoding database URLs, API keys, and environment flags into your container images is a bad idea. And I mean “you will regret this at 2 AM when production is down” bad. Kubernetes gives you two dedicated mechanisms for externalizing configuration: ConfigMaps for non-sensitive stuff and Secrets for credentials.
# Create a ConfigMap from literal values
kubectl create configmap app-config \
--from-literal=NODE_ENV=production \
--from-literal=LOG_LEVEL=info
# Create a Secret for database credentials
kubectl create secret generic db-creds \
--from-literal=DB_HOST=postgres.default.svc \
--from-literal=DB_PASSWORD=supersecret123
# Reference them in your deployment spec under containers:
# envFrom:
# - configMapRef:
# name: app-config
# - secretRef:
# name: db-creds
Once you’ve created these, you wire them into your Deployment by adding an envFrom block under your container spec. Kubernetes injects the key-value pairs as environment variables when the pod starts. Change a ConfigMap value? You can restart your pods (or better, use a tool like Reloader that watches for changes and triggers rolling restarts automatically).
One thing worth mentioning — Kubernetes Secrets aren’t exactly encrypted by default. They’re base64-encoded, which is encoding, not encryption. Anyone with access to the cluster’s etcd datastore can read them. For genuine secret management, you’d want something like HashiCorp Vault or AWS Secrets Manager with the external secrets operator. But for learning, the built-in Secrets work fine. Just… don’t put real production passwords in there on an unencrypted cluster, yeah?
Rolling Updates and Rollbacks: Deploying Without Downtime
Alright, so your app is running with three replicas behind a service. Traffic is flowing. Life is good. Now you need to push a new version. In the pre-K8s world, that probably meant SSH-ing into servers, pulling new code, restarting processes, and praying nothing blew up during the transition.
Kubernetes handles this with rolling updates, and it’s probably my favorite thing about the whole system.
# Update the image (triggers rolling update)
kubectl set image deployment/node-app node-app=node:22-alpine
# Watch the rollout
kubectl rollout status deployment/node-app
# Something wrong? Roll back instantly
kubectl rollout undo deployment/node-app
# View rollout history
kubectl rollout history deployment/node-app
When you run that set image command, here’s what actually happens behind the scenes. Kubernetes creates a new ReplicaSet with the updated image. It starts spinning up pods in the new ReplicaSet one at a time (because we set maxSurge: 1). Each new pod has to pass its readiness probe before K8s routes traffic to it. Only after a new pod is confirmed healthy does K8s terminate an old one. So at no point during the update do you have fewer than your desired number of healthy pods.
And rollbacks? Instant. kubectl rollout undo just tells Kubernetes to switch back to the previous ReplicaSet, which it keeps around specifically for this purpose. I’ve done emergency rollbacks in production that completed in under 10 seconds. Try doing that with manual deployments.
Tip: Always add
--recordwhen runningkubectl applyorset imagecommands, so the rollout history shows what command triggered each revision. Makes debugging a lot easier when you’re staring at five revision entries wondering which one broke things.
Resource Requests and Limits: Don’t Skip These
I want to circle back to something in our Deployment YAML that a lot of beginners gloss over — the resources block.
resources:
requests:
memory: "64Mi"
cpu: "50m"
limits:
memory: "128Mi"
cpu: "100m"
requests tell the Kubernetes scheduler how much CPU and memory your container needs at minimum. It uses these numbers to decide which node to place the pod on. limits set hard ceilings — if your container tries to use more memory than the limit, K8s kills it (OOMKilled). If it tries to use more CPU, it gets throttled.
Skipping resource definitions feels harmless when you’re learning, but on a shared cluster? One runaway pod without limits can starve everything else on the node. I’ve seen a developer’s test workload consume 14 GB of RAM on a shared staging cluster because they forgot to set limits. Took down six other team’s services. Not a fun Friday afternoon.
50m means 50 millicpus, which is 5% of a single CPU core. 64Mi is 64 mebibytes of memory. These units look weird at first but you get used to them fast.
Essential kubectl Commands You’ll Actually Use
Before we wrap up, here’s a cheat sheet. I probably run these 50 times a day when I’m actively working with K8s:
# Cluster info
kubectl cluster-info
kubectl get nodes
# Workloads
kubectl get pods -o wide
kubectl get deployments
kubectl get replicasets
kubectl top pods # CPU/memory usage
# Debugging
kubectl describe pod <name>
kubectl logs <pod> -f # stream logs
kubectl logs <pod> --previous # logs from crashed container
kubectl exec -it <pod> -- sh
# Cleanup
kubectl delete -f deployment.yaml
kubectl delete -f service.yaml
A few notes on these. kubectl get pods -o wide gives you the node name and IP address alongside the usual output — super useful when you’re debugging networking issues. kubectl logs --previous shows logs from the last terminated container in a pod, which is exactly what you need when a pod crash-loops and the current container has no logs yet. And kubectl top pods requires the metrics-server add-on (run minikube addons enable metrics-server if you’re on Minikube).
Tip: Set up shell aliases for kubectl.
alias k=kubectl,alias kgp="kubectl get pods",alias kd="kubectl describe". Sounds trivial, but when you’re typing kubectl 200 times a day, those saved keystrokes add up. Most K8s engineers I know have at least a dozen aliases configured.
Where Stuff Goes Wrong (And How to Fix It)
Let me share the three most common issues beginners hit, because I guarantee you’ll encounter at least one of these in your first week:
ImagePullBackOff — Kubernetes can’t pull your container image. Usually a typo in the image name, a private registry without credentials configured, or your cluster doesn’t have internet access. Run kubectl describe pod <name> and look at the Events section at the bottom. It’ll tell you exactly what failed.
CrashLoopBackOff — Your container starts, crashes, restarts, crashes, restarts… Kubernetes keeps trying because it’s optimistic like that. Check kubectl logs <pod> --previous to see what the app printed before it died. Nine times out of ten, it’s a missing environment variable or a database connection that can’t be established.
Pending pods — The scheduler can’t find a node with enough resources to place your pod. Either your resource requests are too high, or your cluster is genuinely out of capacity. kubectl describe pod will show a scheduling failure event with the specific reason.
Getting comfortable with kubectl describe and kubectl logs will solve maybe 80% of your debugging needs. For the other 20%, you’ll want to start exploring kubectl get events --sort-by=.metadata.creationTimestamp to see a timeline of everything happening in your namespace.
What Comes After This
We’ve covered the foundational pieces — pods, deployments, services, ConfigMaps, Secrets, rolling updates, resource management. That’s genuinely enough to deploy and operate a basic application on Kubernetes. But there’s a whole ecosystem sitting on top of these primitives.
Namespaces let you carve up a single cluster into isolated sections for different teams or environments. Ingress controllers handle HTTP routing so you don’t need a separate LoadBalancer per service. Helm packages your YAML manifests into reusable charts (think npm, but for Kubernetes configs). Horizontal Pod Autoscaler automatically adjusts your replica count based on CPU or custom metrics.
Each of those deserves its own deep dive, and I might tackle them in future posts. For now, I’d suggest spending a solid week with just the basics we covered here. Break things on purpose. Delete pods while watching the deployment recreate them. Push bad images and practice rollbacks. Kill the Minikube node and see what happens when it comes back.
Kubernetes rewards the curious and punishes the copy-pasters, so make sure you actually understand why each piece of that YAML exists before moving on to the fancy stuff.
Container orchestration stops being scary right around the moment your deployment self-heals for the first time — and after that, you’ll wonder how you ever lived without it.