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.
  • kubectl configured to talk to that cluster. kubectl get nodes must list your nodes as Ready.

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

ObjectWhat it doesYou create it when
PodRuns one or more containers that share an IP address and volumesAlmost never directly; for one-off tests
DeploymentManages a ReplicaSet that keeps N identical Pods running, and handles rolling updatesFor every stateless application
ServiceGives a group of Pods, selected by labels, a stable DNS name and virtual IPWhenever 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.matchLabels must match template.metadata.labels. This is how the Deployment finds its Pods.
  • resources.requests tells the scheduler how much CPU and memory to reserve; the memory limit stops a leaking container from taking the whole node.
  • readinessProbe keeps a Pod out of the Service until it answers, and livenessProbe restarts 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:

TypeReachable fromTypical use
ClusterIP (default)Inside the cluster onlyInternal APIs, databases
NodePortEvery node's IP on a port in 30000-32767Testing, or behind an external load balancer
LoadBalancerAn external IP from the cloud or MetalLBPublic services when the cluster supports it
Headless (clusterIP: None)DNS returns Pod IPs directlyStatefulSets, 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.

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.