Kubernetes is huge. Most “learn Kubernetes” content tries to teach you the whole platform — and you end up knowing the OSI model of CRDs, but still unsure how to actually deploy your app.

This post is the opposite. It’s the subset of Kubernetes an application developer actually needs to deploy a typical web service: pods, deployments, services, ingress, configs, secrets, and a bit of operational hygiene. You’ll come out the other side able to ship a real app onto a cluster — and able to read a kubectl describe without panic.

This is not a guide to running Kubernetes (that’s an entirely different job). Use a managed cluster (GKE, EKS, AKS, DigitalOcean, Civo) and don’t try to run the control plane yourself.

The mental model in one paragraph

Kubernetes runs containers on nodes (machines). A pod is one or more tightly-coupled containers that always run together. A deployment is a recipe for keeping N pods running and rolling out new versions. A service is a stable network address that load-balances across the pods. Ingress routes external HTTP traffic into services. ConfigMaps and Secrets inject configuration. That’s the core.

Everything else is optimization, automation, or operations.

Setup

You need three things:

  1. A cluster (managed by your cloud provider, or kind/k3d locally for learning).
  2. kubectlbrew install kubectl or equivalent.
  3. A kubeconfig file (your cloud provider gives you one).

Verify:

kubectl get nodes
# NAME       STATUS   ROLES           AGE   VERSION
# node-1     Ready    control-plane   1d    v1.30.0

Your first pod

You won’t usually create pods directly, but understanding them matters:

# pod.yaml
apiVersion: v1
kind: Pod
metadata:
  name: hello
spec:
  containers:
    - name: app
      image: nginx:1.27-alpine
      ports:
        - containerPort: 80

Apply it:

kubectl apply -f pod.yaml
kubectl get pods
kubectl logs hello
kubectl port-forward hello 8080:80   # local-only access
kubectl delete -f pod.yaml

A pod is the unit of scheduling. If a pod crashes, it stays crashed — there’s no auto-recovery. That’s why we use deployments.

Deployments: keep N copies running

# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: api
  labels: { app: api }
spec:
  replicas: 3
  selector:
    matchLabels: { app: api }
  template:
    metadata:
      labels: { app: api }
    spec:
      containers:
        - name: api
          image: ghcr.io/alzywelzy/api:v1.2.3
          ports:
            - containerPort: 8080
          env:
            - name: PORT
              value: "8080"
          resources:
            requests:
              cpu: "100m"      # 0.1 CPU
              memory: "128Mi"
            limits:
              cpu: "500m"
              memory: "256Mi"
          readinessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 5
            periodSeconds: 10
          livenessProbe:
            httpGet:
              path: /health
              port: 8080
            initialDelaySeconds: 15
            periodSeconds: 30

Apply it:

kubectl apply -f deployment.yaml
kubectl get deploy
kubectl get pods    # 3 pods, all named api-<hash>-<id>
kubectl logs deploy/api

Deploy a new version: bump the image tag and kubectl apply -f again. K8s rolls it out — new pods start, old pods drain.

A few things in this YAML matter a lot:

  • resources.requests is what the scheduler reserves; limits is the cap. Without them, K8s can pack too much onto a node.
  • readinessProbe decides whether a pod is ready to receive traffic. Without it, K8s sends traffic to a half-started pod that 500s.
  • livenessProbe restarts a pod that becomes unresponsive. Don’t make it the same as readinessProbe — liveness should be a health check, not a readiness check.

Services: stable network addresses

Pods are ephemeral and have changing IPs. A Service gives you a stable DNS name that load-balances across all pods matching a label selector:

# service.yaml
apiVersion: v1
kind: Service
metadata:
  name: api
spec:
  type: ClusterIP
  selector:
    app: api
  ports:
    - port: 80
      targetPort: 8080

Now any pod inside the cluster can reach http://api:80/ and get load-balanced to one of the deployment’s pods.

type: ClusterIP (default) — internal-only. type: LoadBalancer provisions an external load balancer (cloud-specific, costs money). type: NodePort opens a port on every node (rarely the right choice).

For exposing one or many services to the internet, use Ingress, not LoadBalancer per service.

Ingress: HTTP routing into the cluster

# ingress.yaml
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: api-ingress
  annotations:
    cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
  ingressClassName: nginx
  tls:
    - hosts: [api.example.com]
      secretName: api-tls
  rules:
    - host: api.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: api
                port:
                  number: 80

Ingress requires an Ingress Controller running in the cluster — most commonly NGINX Ingress, sometimes Traefik or Envoy. Most managed clusters provide one as an add-on. Pair it with cert-manager for automatic Let’s Encrypt TLS.

For a deeper look at load balancing concepts, see Load Balancers Explained .

ConfigMaps and Secrets

Don’t bake config into your image. Inject it.

ConfigMap (non-secret config)

apiVersion: v1
kind: ConfigMap
metadata:
  name: api-config
data:
  LOG_LEVEL: "info"
  CACHE_TTL_SECONDS: "300"

Reference in the deployment:

envFrom:
  - configMapRef: { name: api-config }

Secret (passwords, keys)

apiVersion: v1
kind: Secret
metadata:
  name: api-secrets
type: Opaque
stringData:
  DATABASE_URL: "postgresql://user:pass@db:5432/api"
  JWT_SECRET: "super-long-random-string"

Apply with kubectl apply -f secret.yaml, but don’t commit secrets to git (the YAML contains the literal values). Use SealedSecrets , SOPS , or your cloud’s native secret manager (AWS Secrets Manager, GCP Secret Manager) for production.

K8s Secret resources are base64-encoded, not encrypted. Anyone with kubectl get secret -o yaml access reads them in plaintext. Treat the cluster RBAC accordingly.

Storage: PersistentVolumes for stateful apps

Stateless apps: don’t need storage; just request more replicas. Stateful apps (databases, queues, file uploads): need a PersistentVolumeClaim:

apiVersion: v1
kind: PersistentVolumeClaim
metadata:
  name: postgres-data
spec:
  accessModes: [ReadWriteOnce]
  resources:
    requests:
      storage: 20Gi
  storageClassName: standard

Mount it into a pod:

volumes:
  - name: data
    persistentVolumeClaim:
      claimName: postgres-data
volumeMounts:
  - name: data
    mountPath: /var/lib/postgresql/data

For databases on Kubernetes, strongly consider using your cloud’s managed database service (RDS, Cloud SQL, Crunchy Bridge) instead. Running stateful systems on K8s is doable but operationally non-trivial.

Namespaces

A namespace is a logical partition. Use them to separate environments (dev, staging, prod) or teams.

kubectl create namespace staging
kubectl apply -f deployment.yaml -n staging

Set a default namespace in your context:

kubectl config set-context --current --namespace=staging

Observability essentials

The bare minimum for understanding what’s happening in your cluster:

kubectl get all                      # everything in the namespace
kubectl describe pod <pod>           # status, events, last error
kubectl logs <pod>                   # current logs
kubectl logs <pod> --previous        # logs from the last crashed instance
kubectl logs -f deploy/api           # tail logs across all pods of a deployment
kubectl exec -it <pod> -- /bin/sh    # shell into a pod
kubectl top pod                      # CPU/memory per pod (needs metrics-server)
kubectl get events --sort-by='.lastTimestamp'  # cluster-level events

Memorize describe and logs --previous. They solve 80% of “why is my pod crashing?”

For production, layer on:

  • Prometheus + Grafana for metrics. The standard.
  • Loki / Elasticsearch / CloudWatch for log aggregation. kubectl logs doesn’t scale.
  • Tempo / Jaeger for traces if you have microservices.

See Observability for Backend Developers: Logs, Metrics, Traces .

Helm and Kustomize

Hand-writing YAML for every environment is tedious. Two main tools to template/customize:

  • Helm — package manager for K8s. Charts are templated YAML; helm install deploys. Great for installing third-party things (Postgres, Redis, ingress controllers).
  • Kustomize — built into kubectl. No templates; just layers of YAML patches. Cleaner for your own apps.

Don’t agonize over the choice. Helm for installing third-party charts, Kustomize for your own services is a perfectly reasonable default in 2026.

A minimal CI/CD shape

Modern app deployment to K8s tends to look like:

  1. Push code → CI builds and tags an image (e.g. ghcr.io/me/api:abc1234).
  2. CI updates the image tag in a Kustomize overlay (or Helm values file) in a separate git repo (the “GitOps” repo).
  3. Argo CD or Flux running in the cluster watches the GitOps repo and applies changes.

This is GitOps. It’s the cleanest model: git is the source of truth for what should be deployed; the cluster reconciles to match.

For a simpler setup (small teams, no GitOps yet), kubectl apply from CI works fine:

# .github/workflows/deploy.yml — sketch
- name: Deploy
  run: |
    sed -i "s|image: .*|image: ghcr.io/me/api:${{ github.sha }}|" k8s/deployment.yaml
    kubectl apply -f k8s/

See GitHub Actions CI/CD for Python Apps .

Operational hygiene

A short list of things that consistently bite teams:

  • Always set resource requests and limits — without them, a misbehaving pod can starve the node.
  • Always have liveness AND readiness probes — and they should not be the same endpoint.
  • Always tag images explicitly — never :latest.
  • Don’t run as root in the container — set securityContext.runAsNonRoot: true.
  • Set a reasonable terminationGracePeriodSeconds so your app has time to drain on rolling deploys.
  • Use PodDisruptionBudgets for HA so cluster maintenance doesn’t take down all replicas.
  • Use HorizontalPodAutoscaler — scale based on CPU/memory or custom metrics.

When NOT to use Kubernetes

Honest disclaimer: K8s is real overhead. For a single-service app handling thousands of requests per minute, it’s overkill. Consider:

  • Render, Fly.io, Railway, Cloud Run — push code, get HTTPS. PaaS that handles deployment for you.
  • A single VPS with Docker Compose + Nginx + Let’s Encrypt — see Deploying Django to Production .

Reach for K8s when you have:

  • Multiple services that need to deploy independently.
  • Multiple environments that need to look like production.
  • A team that needs consistent infra primitives across services.
  • Operational scale where one VPS isn’t enough.

For everything else, simpler often wins.

Conclusion

Kubernetes is a big platform with a small subset that handles 90% of app deployment. Master pods, deployments, services, ingress, config/secrets, and the basic kubectl debugging commands, and you can ship real production workloads. Resist the urge to learn the whole platform until you actually need it.

If you’re not yet at K8s scale, that’s fine. The patterns from Deploying Django to Production and Docker for Python Developers carry you a long way.

Happy clustering!


Building something AI-, backend-, or data-heavy and want a second pair of eyes? I do consulting and freelance work — see my projects and ways to reach me at rajpoot.dev .