Skip to content

Kubernetes Deployment, Service and Ingress: a minimal working example

By · Kubernetes & DevOps · 6 min read · Published

Most Kubernetes tutorials either stop at kubectl run or bury the essentials under Helm charts, operators and service meshes. To put a web application on a cluster and reach it from a browser, you need exactly three objects: a Deployment that runs your containers, a Service that gives them a stable address, and an Ingress that routes outside HTTP traffic to that Service. This guide builds all three for a small HTTP API, explains the labels and ports that connect them (where nearly every beginner bug lives), and shows how to debug each layer.

The manifests below pass strict schema validation for Kubernetes 1.34 with kubeconform. They assume your image is built along the lines of the production-ready Dockerfile guide: it listens on port 8080, runs as a non-root user, and serves /ready and /health.

The Deployment: run the pods

apiVersion: apps/v1
kind: Deployment
metadata:
  name: web-api
  labels:
    app: web-api
spec:
  replicas: 2
  selector:
    matchLabels:
      app: web-api              # (1) which pods this Deployment owns
  template:
    metadata:
      labels:
        app: web-api            # (2) must match (1)
    spec:
      securityContext:
        runAsNonRoot: true
      containers:
        - name: web-api
          image: ghcr.io/acme/web-api:1.4.2
          ports:
            - name: http        # (3) a name the other objects can refer to
              containerPort: 8080
          env:
            - name: PORT
              value: "8080"     # env values must be strings: quote numbers
          readinessProbe:
            httpGet: { path: /ready, port: http }
            periodSeconds: 5
          livenessProbe:
            httpGet: { path: /health, port: http }
            periodSeconds: 10
            failureThreshold: 3
          resources:
            requests: { cpu: 100m, memory: 128Mi }
            limits: { memory: 256Mi }

A Deployment manages a ReplicaSet, which keeps the requested number of pods running and replaces them gradually when you change the image. The pieces that matter:

  • Selector and template labels must match. The API rejects a Deployment where they do not, and the selector cannot be changed after creation, so choose it carefully.
  • An immutable image tag such as 1.4.2, or a digest. With :latest you cannot tell which version is running, and a rollback may not roll anything back.
  • The readiness probe decides whether a pod receives traffic. Without one, a pod gets requests the moment its process starts, before it has connected to the database, which shows up as a burst of 502s on every deploy. The probes and resources guide covers the settings in depth.
  • Resources. Requests are what the scheduler reserves. The memory limit is where the container gets OOM-killed. Leaving out a CPU limit avoids throttling a latency-sensitive API, a common and defensible choice.

The Service: a stable address

Pods come and go, and each new pod gets a new IP address. A Service gives the set of pods one stable virtual IP and DNS name, web-api.<namespace>.svc.cluster.local, and load-balances across the pods that are ready:

apiVersion: v1
kind: Service
metadata:
  name: web-api
spec:
  selector:
    app: web-api                # (4) selects pods by label, same as (2)
  ports:
    - name: http
      port: 80                  # (5) the port clients use
      targetPort: http          # (6) the container port, by name, from (3)

The default type, ClusterIP, is reachable only inside the cluster, which is what you want when an Ingress sits in front. targetPort: http refers to the named container port, so if the app moves to port 9000 you change the Deployment only. Notice that a Service does not reference the Deployment at all. It matches any pod with the right labels. That flexibility is also the most common source of "the Service has no endpoints".

The Ingress: HTTP from outside

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: web-api
spec:
  ingressClassName: nginx       # (7) which controller implements this
  rules:
    - host: api.example.com     # (8) matched against the Host header
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: web-api   # (9) the Service from above
                port:
                  name: http    # (10) the Service port (5), by name

An Ingress is only a routing rule. Something has to implement it: an ingress controller running in the cluster, such as Traefik, HAProxy, the F5 NGINX controller or your cloud provider's load balancer controller. ingressClassName picks which one, and its value depends on what your cluster has installed; kubectl get ingressclass lists them. TLS is an extra tls: section naming a Secret with the certificate, which cert-manager can create and renew for you.

A note on timing: the community ingress-nginx controller, the default in countless tutorials, was retired by the Kubernetes project in March 2026 and no longer receives fixes. The Ingress API itself remains supported, and other controllers implement it. New clusters are increasingly built on its successor, the Gateway API, where the same route looks like this:

apiVersion: gateway.networking.k8s.io/v1
kind: HTTPRoute
metadata:
  name: web-api
spec:
  parentRefs:
    - name: public              # a Gateway your platform team provides
  hostnames: ["api.example.com"]
  rules:
    - matches:
        - path: { type: PathPrefix, value: / }
      backendRefs:
        - name: web-api
          port: 80

The Deployment and Service do not change either way.

How it connects

Follow a request from the browser to your code:

https://api.example.com/orders
  → load balancer → ingress controller        rule matches host (8) and path
  → Service web-api, port 80 (5)               via backend (9), (10)
  → a ready pod with label app=web-api (4)     selector
  → container port 8080 (6) → (3)              targetPort by name

Every arrow is a match by name, label or port, and a typo in any of them breaks the chain without an error at apply time. Kubernetes happily accepts a Service whose selector matches nothing.

Deploy and check each layer

kubectl apply -f app.yaml
kubectl rollout status deployment/web-api           # waits until new pods are ready
kubectl get pods -l app=web-api                     # STATUS Running, READY 1/1
kubectl get endpointslices -l kubernetes.io/service-name=web-api   # pod IPs behind the Service
kubectl port-forward service/web-api 8080:80        # then: curl localhost:8080/health
kubectl get ingress web-api                         # ADDRESS should be filled in
curl -H "Host: api.example.com" http://<ingress-address>/health

Test from the inside out. If port-forwarding to the Service works but the Ingress does not, the problem is the Ingress or controller. If port-forwarding fails, the Ingress is irrelevant. The Host header trick lets you test before DNS is set up.

When it does not work

SymptomUsual cause
Pod PendingRequests too big for any node, or a missing volume. kubectl describe pod shows the scheduler's reason under Events.
ImagePullBackOffWrong image name or tag, or a private registry without imagePullSecrets.
CrashLoopBackOffThe app exits on start: missing config, can't reach the database, or a non-root user that cannot write somewhere. Read kubectl logs deploy/web-api --previous.
CreateContainerConfigErrorrunAsNonRoot with an image whose user is a name, not a numeric UID, or a missing Secret or ConfigMap.
Running but READY 0/1Readiness probe failing: wrong path or port, or the app listens on 127.0.0.1 instead of 0.0.0.0.
Service has no endpointsSelector does not match pod labels, or no pod is ready.
404 from the controllerHost or path does not match a rule, or ingressClassName names a class no controller watches.
502 or 503 via the IngressWrong Service port or targetPort, or no ready endpoints. See HTTP status codes in practice.

kubectl describe on the object that looks wrong, read from the bottom, solves most of these. The Events section usually states the problem in plain words.

Next steps

Once this works, the usual additions are a PodDisruptionBudget so node maintenance never takes all replicas down at once, a HorizontalPodAutoscaler, and moving configuration into a ConfigMap and Secrets. Keep the manifests in Git and apply them from CI or a GitOps tool rather than from laptops.

The Kubernetes YAML Generator produces these three objects from a short form, with probes, resources and optional TLS through cert-manager, and the YAML Formatter catches indentation mistakes before kubectl does.

More Kubernetes & DevOps guides