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:
- A cluster (managed by your cloud provider, or
kind/k3dlocally for learning). kubectl—brew install kubectlor equivalent.- A
kubeconfigfile (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.requestsis what the scheduler reserves;limitsis the cap. Without them, K8s can pack too much onto a node.readinessProbedecides whether a pod is ready to receive traffic. Without it, K8s sends traffic to a half-started pod that 500s.livenessProberestarts 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 logsdoesn’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 installdeploys. 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:
- Push code → CI builds and tags an image (e.g.
ghcr.io/me/api:abc1234). - CI updates the image tag in a Kustomize overlay (or Helm values file) in a separate git repo (the “GitOps” repo).
- 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
terminationGracePeriodSecondsso 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 .