Kubernetes Deployments, Services & Ingress
This covers the Deployment, Service, and Ingress settings, rollout strategy, readiness/liveness probes, and a PodDisruptionBudget, that decide whether a bad release is a non-event or an outage.
The Deployment: replicas, resources, and rollout strategy
The Deployment is the controller that keeps N copies of your pod running and manages the transition between versions. The part people skip is resources and strategy.
apiVersion: apps/v1
kind: Deployment
metadata:
name: checkout-api
labels:
app: checkout-api
spec:
replicas: 4
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
selector:
matchLabels:
app: checkout-api
template:
metadata:
labels:
app: checkout-api
spec:
containers:
- name: checkout-api
image: registry.example.com/checkout-api:1.8.2
ports:
- containerPort: 8080
resources:
requests:
cpu: "250m"
memory: "256Mi"
limits:
memory: "512Mi"maxUnavailable: 0 with maxSurge: 1 means Kubernetes always spins up one extra pod before tearing down an old one, so you never drop below full capacity, at the cost of a slightly slower rollout and briefly running N+1 pods, worth it every time for anything client-facing. The opposite setting (maxUnavailable: 1, maxSurge: 0) kills a pod before its replacement is ready, which is exactly how a "routine deploy" becomes a capacity incident on a service already close to its limits.
On resources, set requests to what the container needs under normal load. The scheduler uses them to place pods, and the HPA computes utilization as a percentage of them. I don't set a CPU limit. CPU is compressible, so a CPU limit only throttles the container when the node has idle cycles to spare, and the CPU request already guarantees its share under contention. Memory works differently. A container that breaches its memory limit gets OOMKilled, so set a memory limit and leave headroom above the request.
Readiness vs. liveness probes: two different questions
Mixing these two up causes more production incidents than anything else on this list: the two probes look identical in YAML but do opposite things when they fail.
- Readiness probe: "should this pod receive traffic right now?" Failing it removes the pod from the Service's endpoint list; it keeps running, just out of rotation.
- Liveness probe: "is this process alive enough to keep, or should it be killed and restarted?" Failing it kills the container.
readinessProbe:
httpGet:
path: /healthz/ready
port: 8080
initialDelaySeconds: 5
periodSeconds: 5
failureThreshold: 3
livenessProbe:
httpGet:
path: /healthz/live
port: 8080
initialDelaySeconds: 15
periodSeconds: 10
failureThreshold: 3Never point your liveness probe at an endpoint that checks downstream dependencies (database, cache, third-party API): if the database has a slow minute, a liveness probe checking DB connectivity kills every pod at once, turning a database blip into a full outage. Readiness can check downstream health; liveness should only check whether its own process is responsive.
Set the failureThreshold × periodSeconds window too short on a liveness probe and it'll restart a pod that's merely slow under load, not broken, dropping in-flight requests. If the whole fleet hits the same load spike, that becomes a self-inflicted crash loop: the "fix" for slowness is repeatedly killing the pods trying to catch up. When in doubt, give liveness a longer, more forgiving window than readiness.
The Service: how traffic actually finds a pod
Pods are disposable and get new IPs constantly; a Service gives them a stable virtual IP and DNS name, using a label selector to decide membership.
apiVersion: v1
kind: Service
metadata:
name: checkout-api
spec:
type: ClusterIP
selector:
app: checkout-api
ports:
- port: 80
targetPort: 8080
protocol: TCPThe selector here (app: checkout-api) has to match the labels on the pod template in the Deployment. The Deployment's own metadata doesn't count. This is the copy-paste bug I see most often. Someone renames the Deployment, forgets to update the pod template labels, and the Service ends up with zero endpoints and no error anywhere. When I hear "the Service isn't working", the first command I run is kubectl get endpointslices -l kubernetes.io/service-name=checkout-api. It replaces kubectl get endpoints, which prints a deprecation warning since Kubernetes 1.33. In my experience, a slice with no endpoints almost always means a selector mismatch.
The Ingress: TLS termination and routing
ClusterIP only routes traffic inside the cluster. An Ingress maps an external hostname to that internal Service and, paired with cert-manager, handles TLS termination without you touching a certificate by hand.
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: checkout-api
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
nginx.ingress.kubernetes.io/ssl-redirect: "true"
spec:
ingressClassName: nginx
tls:
- hosts:
- api.example.com
secretName: checkout-api-tls
rules:
- host: api.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: checkout-api
port:
number: 80ingressClassName: nginx and the nginx.ingress.kubernetes.io/* annotations target the community ingress-nginx controller, which was retired in March 2026 and no longer receives releases or security fixes. The Ingress API itself still works with other controllers, but for a new cluster I'd go straight to Gateway API (below). If you still run ingress-nginx, plan the migration so you don't end up with an unpatched edge proxy in front of production.
The cert-manager.io/cluster-issuer annotation triggers cert-manager to request and renew a certificate automatically, storing it in the checkout-api-tls Secret named under tls.secretName, so certificate expiry stops being something a human has to track.
The Gateway API equivalent
Gateway API is the successor to Ingress. The cluster operator owns the Gateway (listeners, TLS), and each team attaches its own HTTPRoute. Here is the same routing, with TLS still issued by cert-manager:
apiVersion: gateway.networking.k8s.io/v1
kind: Gateway
metadata:
name: public
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
gatewayClassName: gke-l7-global-external-managed # use your implementation's GatewayClass
listeners:
- name: https
hostname: api.example.com
protocol: HTTPS
port: 443
tls:
mode: Terminate
certificateRefs:
- name: checkout-api-tls
---
apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
name: checkout-api
spec:
parentRefs:
- name: public
sectionName: https
hostnames:
- api.example.com
rules:
- matches:
- path:
type: PathPrefix
value: /
backendRefs:
- name: checkout-api
port: 80Check two things before copying this. First, the gatewayClassName depends on your controller. kubectl get gatewayclass lists what's installed, and the one above is GKE's managed external load balancer. Second, cert-manager only watches Gateways once its Gateway API support is enabled (config.gatewayAPI.enabled: true in the Helm values of current releases).
The full request path, including a rolling update:
PodDisruptionBudget: surviving node drains
Everything above protects you during a rollout you control, not a node drain: a cluster autoscaler scaling down, a node cordoned for maintenance, an underlying VM replaced. Without a PodDisruptionBudget, Kubernetes is free to evict every pod of your Deployment on a single node at once, even down to zero replicas.
apiVersion: policy/v1
kind: PodDisruptionBudget
metadata:
name: checkout-api-pdb
spec:
minAvailable: 2
selector:
matchLabels:
app: checkout-apiminAvailable: 2 tells the eviction API never to voluntarily take this Deployment below 2 ready pods. Node drains, autoscaler scale-downs and kubectl drain all go through that API and respect it. With 4 healthy replicas, that still allows two pods to be evicted at the same time. If you want evictions strictly one at a time, use maxUnavailable: 1 instead of minAvailable. It also stays correct when the HPA changes the replica count. Most teams skip this object until a routine node upgrade takes a service down.
None of these objects are complicated individually. Teams get burned shipping them once, copying the YAML from a tutorial, and never coming back to add probes, a rollout strategy, or a PDB before real traffic hits. Add them up front, and a rollout gets boring. In production, boring beats interesting.
Want to actually run this in production?
This tutorial covers the concepts and architecture. If you want to implement it in your own infrastructure, or get good enough to own this problem long-term, I offer 1:1 mentoring built around your real environment, not a generic course.
This tutorial
- Core architecture & key concepts
- Illustrative code snippets
- The reasoning behind each decision
1:1 mentoring
- Working sessions on your own environment
- Direct answers to the edge cases you're hitting
- Feedback on your actual implementation
- Ongoing support as you build it out
Related Tutorials
Enterprise GitOps Pipelines with ArgoCD & Helm
A multi-environment GitOps setup with Argo CD and Helm: app-of-apps, per-environment overlays and self-healing sync.
Containerizing Your First App with Docker Compose
Write a lean multi-stage Dockerfile and a docker-compose.yml that runs your app and its database.
Cut Deployment Lead Time to Under an Hour
Replace manual approval chains with GitOps, Argo CD and a canary, so changes ship in small batches under change control.