Pods, Deployments and Services are the three objects you use for almost every application on Kubernetes. A Pod runs your containers, a Deployment keeps the right number of identical Pods running and replaces them safely during updates, and a Service gives those Pods a stable name and IP address. In this tutorial you will build all three for a small web application, then scale it, update it and roll it back.
Prerequisites
To follow this tutorial you need:
- A running Kubernetes cluster (version 1.30 or later) with at least one worker node. You can build one on CubePath VPS instances with kubeadm on Ubuntu 24.04.
kubectlconfigured to talk to that cluster.kubectl get nodesmust list your nodes asReady.
All the manifests in this guide go in a working directory on the machine where you run kubectl:
mkdir -p ~/k8s-demo && cd ~/k8s-demo
How the three objects relate
| Object | What it does | You create it when |
|---|---|---|
| Pod | Runs one or more containers that share an IP address and volumes | Almost never directly; for one-off tests |
| Deployment | Manages a ReplicaSet that keeps N identical Pods running, and handles rolling updates | For every stateless application |
| Service | Gives a group of Pods, selected by labels, a stable DNS name and virtual IP | Whenever something must reach those Pods |
The link between them is labels. A Deployment stamps a label such as app: web on every Pod it creates, and a Service forwards traffic to every ready Pod that carries that label.
Step 1 - Running a single Pod
Start with a bare Pod to see the smallest unit Kubernetes schedules. Create the manifest:
nano pod.yaml
apiVersion: v1
kind: Pod
metadata:
name: web-test
labels:
app: web-test
spec:
containers:
- name: nginx
image: nginx:1.28
ports:
- containerPort: 80
Apply it and watch it start:
kubectl apply -f pod.yaml
kubectl get pod web-test -o wide
NAME READY STATUS RESTARTS AGE IP NODE
web-test 1/1 Running 0 12s 10.244.1.5 worker-1
kubectl describe shows the scheduling decisions and events, which is the first place to look when a Pod does not start:
kubectl describe pod web-test
Now delete it:
kubectl delete pod web-test
Nothing brings it back. A bare Pod is not rescheduled if it is deleted or its node fails, which is why real workloads use Deployments.
Step 2 - Creating a Deployment
A Deployment describes the desired state: which image, how many replicas and how to update them. Create the manifest:
nano deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: web
labels:
app: web
spec:
replicas: 3
selector:
matchLabels:
app: web
template:
metadata:
labels:
app: web
spec:
containers:
- name: nginx
image: nginx:1.28
ports:
- containerPort: 80
resources:
requests:
cpu: 50m
memory: 64Mi
limits:
memory: 128Mi
readinessProbe:
httpGet:
path: /
port: 80
periodSeconds: 5
livenessProbe:
httpGet:
path: /
port: 80
initialDelaySeconds: 10
periodSeconds: 10
The important parts are:
selector.matchLabelsmust matchtemplate.metadata.labels. This is how the Deployment finds its Pods.resources.requeststells the scheduler how much CPU and memory to reserve; the memorylimitstops a leaking container from taking the whole node.readinessProbekeeps a Pod out of the Service until it answers, andlivenessProberestarts a container that stops answering.
Apply it and wait for the rollout to finish:
kubectl apply -f deployment.yaml
kubectl rollout status deployment/web
deployment "web" successfully rolled out
List the Deployment, the ReplicaSet it created and its Pods:
kubectl get deployment,replicaset,pods -l app=web
NAME READY UP-TO-DATE AVAILABLE AGE
deployment.apps/web 3/3 3 3 30s
NAME DESIRED CURRENT READY AGE
replicaset.apps/web-5d4f8c7b9d 3 3 3 30s
NAME READY STATUS RESTARTS AGE
pod/web-5d4f8c7b9d-8xk2m 1/1 Running 0 30s
pod/web-5d4f8c7b9d-jq7wn 1/1 Running 0 30s
pod/web-5d4f8c7b9d-tz4hb 1/1 Running 0 30s
To see self-healing, delete one Pod (use a name from your own output):
kubectl delete pod web-5d4f8c7b9d-8xk2m
kubectl get pods -l app=web
A replacement Pod appears within seconds, because the ReplicaSet always works towards three replicas.
Step 3 - Exposing the Deployment with a Service
Pod IPs change every time a Pod is replaced, so clients should never use them. A Service provides a stable address. Kubernetes offers several types:
| Type | Reachable from | Typical use |
|---|---|---|
ClusterIP (default) | Inside the cluster only | Internal APIs, databases |
NodePort | Every node's IP on a port in 30000-32767 | Testing, or behind an external load balancer |
LoadBalancer | An external IP from the cloud or MetalLB | Public services when the cluster supports it |
Headless (clusterIP: None) | DNS returns Pod IPs directly | StatefulSets, client-side load balancing |
Create a ClusterIP Service for the Deployment:
nano service.yaml
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP
selector:
app: web
ports:
- port: 80
targetPort: 80
Apply it and check that it found the three Pods. The endpoints list must contain three Pod IPs:
kubectl apply -f service.yaml
kubectl get service web
kubectl get endpointslices -l kubernetes.io/service-name=web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web ClusterIP 10.103.52.17 <none> 80/TCP 5s
NAME ADDRESSTYPE PORTS ENDPOINTS AGE
web-7h2kp IPv4 80 10.244.1.6,10.244.2.4,10.244.1.7 5s
Test it from inside the cluster with a temporary Pod. The DNS name web resolves within the same namespace:
kubectl run curl-test --rm -it --image=curlimages/curl --restart=Never -- curl -sI http://web
HTTP/1.1 200 OK
Server: nginx/1.28.0
To reach the application from outside the cluster for a quick test, change the type to NodePort:
kubectl patch service web -p '{"spec":{"type":"NodePort"}}'
kubectl get service web
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
web NodePort 10.103.52.17 <none> 80:30712/TCP 2m
The application now answers on http://your_node_ip:30712. For production HTTP traffic, keep the Service as ClusterIP and publish it through an Ingress controller instead.
Step 4 - Scaling the Deployment
Change the replica count on the command line:
kubectl scale deployment/web --replicas=5
kubectl get pods -l app=web
The Service picks up the two new Pods automatically as soon as their readiness probes pass. The imperative scale command is fine for experiments, but kubectl apply will reset the count to the value in deployment.yaml. To make the change permanent, edit replicas in the file and apply it again.
Step 5 - Rolling out a new version
When you change the Pod template, the Deployment creates a new ReplicaSet and moves Pods over gradually. Control the pace with the update strategy. Add this block under spec: in deployment.yaml, at the same level as replicas:
strategy:
type: RollingUpdate
rollingUpdate:
maxSurge: 1
maxUnavailable: 0
With maxUnavailable: 0, Kubernetes starts a new Pod and waits for it to become ready before removing an old one, so capacity never drops.
Now change the image to nginx:1.29 in deployment.yaml and apply the file, following the rollout:
kubectl apply -f deployment.yaml
kubectl rollout status deployment/web
Waiting for deployment "web" rollout to finish: 1 out of 3 new replicas have been updated...
Waiting for deployment "web" rollout to finish: 2 out of 3 new replicas have been updated...
deployment "web" successfully rolled out
Record why you made the change, so it shows up in the history:
kubectl annotate deployment/web kubernetes.io/change-cause="upgrade nginx to 1.29"
kubectl rollout history deployment/web
deployment.apps/web
REVISION CHANGE-CAUSE
1 <none>
2 upgrade nginx to 1.29
Step 6 - Rolling back a bad release
Simulate a broken release by setting an image tag that does not exist:
kubectl set image deployment/web nginx=nginx:does-not-exist
kubectl get pods -l app=web
NAME READY STATUS RESTARTS AGE
web-6b8d9f5c4-2lq9x 0/1 ImagePullBackOff 0 40s
web-7f9c6d8b5-4mz8p 1/1 Running 0 5m
web-7f9c6d8b5-9ktrw 1/1 Running 0 5m
web-7f9c6d8b5-vx2jd 1/1 Running 0 5m
Because maxUnavailable is 0, the three working Pods keep serving traffic while the new one fails. Roll back to the previous revision:
kubectl rollout undo deployment/web
kubectl rollout status deployment/web
The broken Pod is removed and the Deployment is back on nginx:1.29. Use kubectl rollout undo deployment/web --to-revision=1 to go back to a specific revision.
Note
kubectl set imageandrollout undochange the live object, not your YAML file. Updatedeployment.yamlso the file stays the source of truth.
Step 7 - Cleaning up
Delete the objects you created by pointing kubectl at the manifests:
kubectl delete -f service.yaml -f deployment.yaml
Troubleshooting
Pod stuck in Pending. No node has enough free CPU or memory for the requests, or a taint blocks scheduling. The Events section of kubectl describe pod <name> states the reason.
ImagePullBackOff or ErrImagePull. The image name or tag is wrong, or the registry needs credentials. Check the exact error in kubectl describe pod <name>.
CrashLoopBackOff. The container starts and exits. Read the logs of the previous attempt with kubectl logs <name> --previous.
The Service does not answer. Run kubectl get endpointslices -l kubernetes.io/service-name=web. If there are no endpoints, the Service selector does not match the Pod labels, or the Pods are failing their readiness probe.
Conclusion
You deployed an application with a Deployment, exposed it through a Service, scaled it, and performed a rolling update and a rollback without downtime. The same pattern (a Deployment, labels, and a Service selecting them) is the base of almost every stateless workload on Kubernetes.
Next, keep the essential kubectl commands at hand, publish the Service over HTTPS with an Ingress, and move configuration into ConfigMaps and Secrets instead of baking it into images.
