DevOps from Zero to Hero: Kubernetes Fundamentals

2026-05-21 | Gabriel Garrido | 17 min read
Share:

Support this blog

If you find this content useful, consider supporting the blog.

Introduction

Welcome to article eleven of the DevOps from Zero to Hero series. In article eight we deployed our TypeScript API to AWS ECS with Fargate. ECS is a solid container orchestrator, but it is AWS-specific. If you want something that runs on any cloud provider, on bare metal, or even on your laptop, Kubernetes is the answer.


Kubernetes (often shortened to K8s) is the industry standard for container orchestration. It is what most teams end up using when they need to run containers at scale. It is also one of those technologies that looks intimidating from the outside but makes a lot of sense once you understand the core concepts.


In this article we will cover what Kubernetes is and why it exists, walk through the architecture, learn about every core object you will use daily, set up a local cluster with kind, and deploy a real workload step by step. By the end you will be comfortable reading Kubernetes manifests, running kubectl commands, and understanding what is happening inside a cluster.


Let’s get into it.


What is Kubernetes and why does it exist?

Imagine you have ten containers that need to run across five servers. Some containers need to talk to each other. Some need more CPU than others. If one crashes, you want it restarted automatically. If traffic spikes, you want to spin up more copies. And you want all of this to happen without you waking up at 3 AM.


That is container orchestration, and that is what Kubernetes does. It takes a set of machines, pools their resources together, and lets you declare what you want running. Kubernetes then figures out where to place each container, keeps everything healthy, and handles networking so containers can find each other.


The key capabilities are:


  • Scheduling: Kubernetes decides which node (server) each container runs on based on available resources.
  • Scaling: You tell Kubernetes how many copies of a container you want. It makes it happen. You can also set up auto-scaling based on CPU, memory, or custom metrics.
  • Self-healing: If a container crashes, Kubernetes restarts it. If a node goes down, Kubernetes reschedules the containers that were running on it to healthy nodes.
  • Service discovery and load balancing: Kubernetes gives each set of containers a stable network identity and balances traffic across them automatically.
  • Rolling updates and rollbacks: You can update your application with zero downtime. If something goes wrong, you can roll back to the previous version with a single command.
  • Declarative configuration: You describe what you want in YAML files, and Kubernetes continuously works to make reality match your description. This is called the “desired state” model.

Kubernetes was originally designed by Google, based on their internal system called Borg. It was open sourced in 2014 and is now maintained by the Cloud Native Computing Foundation (CNCF). Every major cloud provider offers a managed Kubernetes service: EKS on AWS, GKE on Google Cloud, AKS on Azure.


Architecture overview

A Kubernetes cluster has two types of components: the control plane (the brain) and the worker nodes (the muscle). Here is how they fit together:


+-----------------------------------------------------------+
|                     Kubernetes Cluster                     |
|                                                           |
|  +-----------------------------------------------------+  |
|  |                   Control Plane                      |  |
|  |                                                     |  |
|  |  +--------------+  +-------+  +-----------+         |  |
|  |  |  API Server  |  | etcd  |  | Scheduler |         |  |
|  |  +--------------+  +-------+  +-----------+         |  |
|  |  +--------------------+                             |  |
|  |  | Controller Manager |                             |  |
|  |  +--------------------+                             |  |
|  +-----------------------------------------------------+  |
|                                                           |
|  +------------------------+  +------------------------+   |
|  |     Worker Node 1      |  |     Worker Node 2      |   |
|  |                        |  |                        |   |
|  |  +--------+ +-------+ |  |  +--------+ +-------+  |   |
|  |  | kubelet| | proxy | |  |  | kubelet| | proxy |  |   |
|  |  +--------+ +-------+ |  |  +--------+ +-------+  |   |
|  |  +------+ +------+    |  |  +------+ +------+     |   |
|  |  | Pod  | | Pod  |    |  |  | Pod  | | Pod  |     |   |
|  |  +------+ +------+    |  |  +------+ +------+     |   |
|  +------------------------+  +------------------------+   |
+-----------------------------------------------------------+

Let’s break down each component:


Control plane components

  • API Server (kube-apiserver): The front door to your cluster. Every command you run with kubectl goes through the API server. It validates requests, updates the cluster state, and is the only component that talks directly to etcd. Think of it as the receptionist that handles all incoming requests.
  • etcd: A distributed key-value store that holds the entire state of your cluster. Every object you create, every configuration, every secret is stored here. If etcd is lost and you have no backup, your cluster state is gone. It is the single source of truth.
  • Scheduler (kube-scheduler): When you create a new Pod and it does not have a node assigned yet, the scheduler picks one. It looks at resource requirements, constraints, and available capacity to make the best placement decision.
  • Controller Manager (kube-controller-manager): Runs a set of controllers that watch the cluster state and work to make reality match the desired state. For example, the ReplicaSet controller ensures the right number of Pod replicas are running. If you ask for three replicas and only two are running, it creates another one.

Worker node components

  • kubelet: An agent that runs on every worker node. It receives Pod specifications from the API server and ensures the containers described in those specs are running and healthy. If a container crashes, kubelet restarts it.
  • kube-proxy: Manages network rules on each node. It handles the networking magic that lets you reach any Pod from any node using a Service. It sets up iptables rules (or IPVS, depending on configuration) to route traffic correctly.
  • Container runtime: The software that actually runs containers. Kubernetes supports any runtime that implements the Container Runtime Interface (CRI). The most common ones are containerd and CRI-O. Docker used to be the default, but Kubernetes removed direct Docker support in version 1.24 (containerd, which Docker uses under the hood, is still fully supported).

Core objects: Pods

A Pod is the smallest deployable unit in Kubernetes. It is not a container. It is a wrapper around one or more containers that share the same network namespace and storage volumes.


Most of the time a Pod runs a single container. But sometimes you need a helper container alongside your main one (for logging, proxying, or injecting configuration). Those are called sidecar containers, and they live in the same Pod.


Containers in the same Pod:


  • Share the same IP address and can talk to each other via localhost
  • Share storage volumes mounted into the Pod
  • Are scheduled together on the same node
  • Start and stop together as a unit

Here is a simple Pod definition:


apiVersion: v1
kind: Pod
metadata:
  name: my-nginx
  labels:
    app: nginx
spec:
  containers:
    - name: nginx
      image: nginx:1.27
      ports:
        - containerPort: 80

You almost never create Pods directly in production. Instead, you use a Deployment (which we will cover next) that manages Pods for you. If you create a Pod directly and it crashes, nothing will restart it. A Deployment ensures crashed Pods are replaced automatically.


Core objects: Deployments

A Deployment is the most common way to run workloads in Kubernetes. It wraps a Pod template and adds powerful management features on top.


When you create a Deployment, you tell Kubernetes: “I want three replicas of this container, always running, and here is how to update them.” Kubernetes then creates a ReplicaSet behind the scenes, and the ReplicaSet creates the Pods. The chain looks like this:


Deployment
  └── ReplicaSet
        ├── Pod 1
        ├── Pod 2
        └── Pod 3

Here is a Deployment manifest:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 3
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          resources:
            requests:
              memory: "64Mi"
              cpu: "100m"
            limits:
              memory: "128Mi"
              cpu: "250m"

Key features of Deployments:


  • Desired state: You declare how many replicas you want. If a Pod dies, the Deployment creates a new one. If you have too many, it terminates the extras.
  • Rolling updates: When you change the container image, the Deployment gradually replaces old Pods with new ones, ensuring zero downtime. By default it takes down at most 25% of Pods at a time while bringing up new ones.
  • Rollback: Every change to a Deployment creates a new revision. If a new version is broken, you can roll back to any previous revision with kubectl rollout undo.
  • Scaling: Change the replica count and Kubernetes handles the rest. Scale up or down at any time.

Core objects: Services

Pods are ephemeral. They get created, destroyed, and moved around constantly. Each time a Pod is recreated, it gets a new IP address. So how do other Pods find and talk to your application?


That is what Services solve. A Service provides a stable network endpoint (a fixed IP and DNS name) that routes traffic to a set of Pods. Even as Pods come and go, the Service keeps pointing to the healthy ones.


There are three main types:


  • ClusterIP (default): Creates an internal IP address that is only reachable from within the cluster. This is what you use for service-to-service communication. For example, your API talking to your database.
  • NodePort: Exposes the service on a static port on every node in the cluster. You can reach it from outside by hitting any node’s IP at that port. Useful for development, but not ideal for production.
  • LoadBalancer: Provisions an external load balancer (on cloud providers). This is the standard way to expose a service to the internet in production. On AWS it creates an ELB, on GCP a Cloud Load Balancer, and so on.

Here is a Service that exposes our nginx Deployment:


apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: ClusterIP
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80

The selector field is what connects a Service to its Pods. The Service looks for all Pods with the label app: nginx and routes traffic to them. This is the label-selector mechanism and it is fundamental to how Kubernetes connects objects together.


Core objects: ConfigMaps and Secrets

Applications need configuration: database URLs, feature flags, API keys. Hardcoding these values into your container image is a bad idea because you would need to rebuild the image for every environment.


Kubernetes solves this with ConfigMaps and Secrets:


  • ConfigMap: Stores non-sensitive configuration as key-value pairs. Things like environment names, log levels, and feature flags.
  • Secret: Stores sensitive data like passwords, tokens, and certificates. Secrets are base64-encoded (not encrypted by default, but you can enable encryption at rest). In production, use a secrets manager like HashiCorp Vault or AWS Secrets Manager and sync secrets into Kubernetes with an operator.

Here is a ConfigMap:


apiVersion: v1
kind: ConfigMap
metadata:
  name: app-config
data:
  LOG_LEVEL: "info"
  APP_ENV: "production"
  MAX_CONNECTIONS: "100"

And a Secret:


apiVersion: v1
kind: Secret
metadata:
  name: app-secrets
type: Opaque
data:
  DATABASE_URL: cG9zdGdyZXM6Ly91c2VyOnBhc3NAaG9zdDo1NDMyL2Ri
  API_KEY: c3VwZXItc2VjcmV0LWtleQ==

You can inject these into Pods as environment variables or mount them as files. Here is how to use both in a Deployment:


spec:
  containers:
    - name: app
      image: my-app:1.0
      envFrom:
        - configMapRef:
            name: app-config
        - secretRef:
            name: app-secrets

Core objects: Namespaces

Namespaces provide logical isolation within a cluster. They are like folders for your Kubernetes objects. Different teams, environments, or applications can each have their own namespace.


Every cluster starts with a few default namespaces:


  • default: Where objects go if you do not specify a namespace.
  • kube-system: Where Kubernetes system components run (API server, scheduler, CoreDNS, etc.).
  • kube-public: Readable by all users, used for cluster-wide public information.
  • kube-node-lease: Holds lease objects for node heartbeats.

Creating a namespace is simple:


kubectl create namespace staging

Or with YAML:


apiVersion: v1
kind: Namespace
metadata:
  name: staging

Then deploy resources into that namespace:


kubectl apply -f deployment.yaml -n staging

Namespaces are also the boundary for resource quotas and network policies. You can limit how much CPU and memory a namespace can consume, and you can control which namespaces can talk to each other.


Labels and selectors

Labels are key-value pairs attached to any Kubernetes object. They are the glue that connects different objects together.


metadata:
  labels:
    app: nginx
    environment: production
    team: platform
    version: "1.27"

Selectors filter objects based on their labels. This is how a Service finds its Pods, how a Deployment knows which Pods it owns, and how you can query specific objects with kubectl:


# Get all pods with a specific label
kubectl get pods -l app=nginx

# Get pods matching multiple labels
kubectl get pods -l app=nginx,environment=production

# Get pods where a label exists (any value)
kubectl get pods -l team

# Get pods where a label does NOT exist
kubectl get pods -l '!team'

Labels and selectors are not just a nice-to-have. They are how Kubernetes works internally. If your Service selector does not match your Pod labels, traffic will not flow. If your Deployment selector does not match the Pod template labels, the Deployment will reject the configuration.


Resource requests and limits

Every container should declare how much CPU and memory it needs. Without this, Kubernetes has no idea how to schedule Pods efficiently and you risk overloading nodes.


There are two settings:


  • Requests: The minimum amount of resources guaranteed to the container. The scheduler uses requests to decide which node has enough room for the Pod. If you request 256Mi of memory, Kubernetes will place the Pod on a node with at least that much available.
  • Limits: The maximum amount of resources a container can use. If a container exceeds its memory limit, Kubernetes kills it (OOMKilled). If it exceeds its CPU limit, it gets throttled (slowed down but not killed).

resources:
  requests:
    memory: "128Mi"
    cpu: "100m"
  limits:
    memory: "256Mi"
    cpu: "500m"

CPU is measured in millicores. 100m means 0.1 CPU cores. 1000m (or just 1) means one full core. Memory uses standard units: Mi (mebibytes), Gi (gibibytes).


A few rules of thumb:


  • Always set requests. Without them, the scheduler is guessing.
  • Set memory limits to prevent runaway containers from crashing the node.
  • Be careful with CPU limits. Aggressive CPU limits cause throttling even when the node has spare CPU. Some teams set CPU requests but skip CPU limits to avoid unnecessary throttling.
  • Monitor actual usage and adjust requests/limits based on real data, not guesses.

Setting up a local cluster with kind

kind (Kubernetes in Docker) is the fastest way to get a local Kubernetes cluster running. It creates a cluster by running Kubernetes nodes as Docker containers. You need Docker installed and that is it.


Install kind:


# On Linux
curl -Lo ./kind https://kind.sigs.k8s.io/dl/v0.27.0/kind-linux-amd64
chmod +x ./kind
sudo mv ./kind /usr/local/bin/kind

# On macOS (Homebrew)
brew install kind

Create a cluster:


kind create cluster --name my-cluster

That is it. kind creates a single-node cluster and configures kubectl to use it. Verify it is running:


kubectl cluster-info --context kind-my-cluster
kubectl get nodes

You should see output like:


NAME                       STATUS   ROLES           AGE   VERSION
my-cluster-control-plane   Ready    control-plane   45s   v1.32.2

When you are done, delete the cluster:


kind delete cluster --name my-cluster

kubectl basics

kubectl is the command-line tool for interacting with Kubernetes. Here are the commands you will use every day:


# Get resources
kubectl get pods                     # List all pods in current namespace
kubectl get pods -A                  # List pods in ALL namespaces
kubectl get deployments              # List deployments
kubectl get services                 # List services
kubectl get all                      # List common resource types

# Detailed information about a resource
kubectl describe pod my-nginx        # Show events, conditions, containers
kubectl describe deployment nginx-deployment

# View logs
kubectl logs my-nginx                # Logs from a pod
kubectl logs my-nginx -f             # Stream logs (follow)
kubectl logs my-nginx --previous     # Logs from the previous container (after crash)

# Execute commands inside a container
kubectl exec -it my-nginx -- /bin/bash   # Interactive shell
kubectl exec my-nginx -- cat /etc/nginx/nginx.conf  # Run a single command

# Apply and delete resources from files
kubectl apply -f deployment.yaml     # Create or update resources from a file
kubectl apply -f ./manifests/        # Apply all files in a directory
kubectl delete -f deployment.yaml    # Delete resources defined in a file
kubectl delete pod my-nginx          # Delete a specific pod

A few tips that will save you time:


  • Use -o wide to see extra columns like node name and IP: kubectl get pods -o wide
  • Use -o yaml to see the full object definition: kubectl get pod my-nginx -o yaml
  • Set a default namespace so you do not have to type -n every time: kubectl config set-context --current --namespace=staging
  • Use aliases. Most Kubernetes users alias kubectl to k: alias k=kubectl

Practical walkthrough: deploy, expose, scale, update

Let’s put everything together with a hands-on exercise. Make sure you have a kind cluster running.


Step 1: Create a Deployment


Create a file called nginx-deployment.yaml:


apiVersion: apps/v1
kind: Deployment
metadata:
  name: nginx-deployment
  labels:
    app: nginx
spec:
  replicas: 2
  selector:
    matchLabels:
      app: nginx
  template:
    metadata:
      labels:
        app: nginx
    spec:
      containers:
        - name: nginx
          image: nginx:1.27
          ports:
            - containerPort: 80
          resources:
            requests:
              memory: "64Mi"
              cpu: "50m"
            limits:
              memory: "128Mi"
              cpu: "100m"

Apply it:


kubectl apply -f nginx-deployment.yaml

Check the results:


kubectl get deployments
kubectl get pods

You should see two Pods running:


NAME                                READY   STATUS    RESTARTS   AGE
nginx-deployment-5d8f4d7b9c-abc12   1/1     Running   0          15s
nginx-deployment-5d8f4d7b9c-def34   1/1     Running   0          15s

Step 2: Expose it with a Service


Create a file called nginx-service.yaml:


apiVersion: v1
kind: Service
metadata:
  name: nginx-service
spec:
  type: NodePort
  selector:
    app: nginx
  ports:
    - protocol: TCP
      port: 80
      targetPort: 80
      nodePort: 30080

Apply it:


kubectl apply -f nginx-service.yaml

Verify the Service:


kubectl get services

NAME            TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
nginx-service   NodePort   10.96.45.123   <none>        80:30080/TCP   5s
kubernetes      ClusterIP  10.96.0.1      <none>        443/TCP        10m

Test that it works. Since we are using kind, we can port-forward to access the service locally:


kubectl port-forward service/nginx-service 8080:80

Now open another terminal and hit it:


curl http://localhost:8080

You should see the default nginx welcome page HTML.


Step 3: Scale the Deployment


Let’s go from two replicas to five:


kubectl scale deployment nginx-deployment --replicas=5

Watch the Pods come up:


kubectl get pods -w

Within seconds you will have five Pods running. Scale back down:


kubectl scale deployment nginx-deployment --replicas=2

Kubernetes will terminate three Pods gracefully.


Step 4: Do a rolling update


Let’s update from nginx 1.27 to 1.28. You can edit the YAML file and re-apply, or do it inline:


kubectl set image deployment/nginx-deployment nginx=nginx:1.28

Watch the rolling update happen:


kubectl rollout status deployment/nginx-deployment

Waiting for deployment "nginx-deployment" rollout to finish: 1 out of 2 new replicas have been updated...
Waiting for deployment "nginx-deployment" rollout to finish: 1 old replicas are pending termination...
deployment "nginx-deployment" successfully rolled out

Kubernetes created new Pods with nginx 1.28 and terminated the old ones, one at a time, with zero downtime.


Check the rollout history:


kubectl rollout history deployment/nginx-deployment

If something goes wrong, roll back:


kubectl rollout undo deployment/nginx-deployment

This reverts to the previous revision immediately.


Step 5: Inspect and debug


Get detailed information about a Pod:


kubectl describe pod nginx-deployment-<tab-complete-the-name>

Check the container logs:


kubectl logs deployment/nginx-deployment

Open a shell inside a running container:


kubectl exec -it deployment/nginx-deployment -- /bin/bash

Inside the container you can inspect files, test connectivity, and debug issues directly.


Step 6: Clean up


kubectl delete -f nginx-service.yaml
kubectl delete -f nginx-deployment.yaml

Or delete the entire kind cluster:


kind delete cluster --name my-cluster

Closing notes

Kubernetes has a reputation for being complex, and it is true that the ecosystem is massive. But the core concepts are straightforward. You have Pods that run containers, Deployments that manage Pods, Services that route traffic, ConfigMaps and Secrets for configuration, and Namespaces for isolation. Everything connects through labels and selectors.


The key insight is that Kubernetes is a declarative system. You tell it what you want, and it continuously works to make that happen. You do not tell it “start three containers.” You tell it “I want three replicas” and it figures out how to get there, whether that means creating new Pods, restarting crashed ones, or rescheduling them to different nodes.


We covered a lot of ground in this article. Set up a kind cluster and play around. Break things on purpose. Delete a Pod and watch the Deployment recreate it. Change resource limits and see what happens. The best way to learn Kubernetes is by using it.


In the next articles we will build on this foundation: deploying real applications to Kubernetes, setting up networking with Ingress controllers, and managing everything with Helm charts.


Hope you found this useful and enjoyed reading it, until next time!


Errata

If you spot any error or have any suggestion, please send me a message so it gets fixed.

Also, you can check the source code and changes in the sources here



$ Comments

Online: 0

Please sign in to be able to write comments.

2026-05-21 | Gabriel Garrido