How to Expose a Service in Kubernetes

How to Expose a Service in Kubernetes

One of the first questions I get from people new to Kubernetes is some version of “I deployed my app, why can’t I reach it?” The answer is almost always the same: Pods get IP addresses, but those IPs are neither stable nor externally reachable on their own, and nothing is exposed by default. You have to explicitly decide how and to whom your application should be reachable. In this guide I’ll walk through every major way to expose a Service in Kubernetes — ClusterIP, NodePort, LoadBalancer, and Ingress — and explain exactly when to reach for each one.

Why Pods Alone Aren’t Enough

Pods are ephemeral. They get recreated, rescheduled, and assigned new IPs constantly. If your frontend hardcoded a backend Pod’s IP address, it would break the moment that Pod restarted. A Service solves this by providing a stable virtual IP and DNS name that load-balances across whatever Pods currently match its label selector — regardless of how many times those Pods come and go.

Step 1: A Baseline Deployment to Expose

# myapp-deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
  name: myapp
spec:
  replicas: 3
  selector:
    matchLabels:
      app: myapp
  template:
    metadata:
      labels:
        app: myapp
    spec:
      containers:
        - name: myapp
          image: myrepo/myapp:1.0.0
          ports:
            - containerPort: 8080
kubectl apply -f myapp-deployment.yaml

Step 2: ClusterIP — Internal-Only Access (the Default)

apiVersion: v1
kind: Service
metadata:
  name: myapp-clusterip
spec:
  type: ClusterIP
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
kubectl apply -f myapp-clusterip.yaml
kubectl get svc myapp-clusterip
NAME               TYPE        CLUSTER-IP     EXTERNAL-IP   PORT(S)   AGE
myapp-clusterip    ClusterIP   10.96.142.87   <none>        80/TCP    10s

This IP is only reachable from inside the cluster. Other Pods reach it via DNS: myapp-clusterip.default.svc.cluster.local. This is the right choice for internal microservices — backend APIs, databases, caches — anything that shouldn’t be reachable from outside the cluster at all.

Test it from another Pod:

kubectl run debug --rm -it --image=busybox -- wget -qO- http://myapp-clusterip

Step 3: NodePort — Exposing on Every Node’s IP

apiVersion: v1
kind: Service
metadata:
  name: myapp-nodeport
spec:
  type: NodePort
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
      nodePort: 30080
kubectl apply -f myapp-nodeport.yaml
kubectl get svc myapp-nodeport
NAME              TYPE       CLUSTER-IP     EXTERNAL-IP   PORT(S)        AGE
myapp-nodeport    NodePort   10.96.201.44   <none>        80:30080/TCP   10s

Now the app is reachable at http://<any-node-ip>:30080 — Kubernetes opens that port on every node in the cluster and routes traffic to the Service regardless of which node actually has a matching Pod. nodePort is optional; if omitted, Kubernetes picks a random port from the default range (30000–32767).

curl http://<node-ip>:30080

NodePort is rarely the right choice for production external traffic — it’s mostly useful for quick testing, on-prem setups without a cloud load balancer, or as the mechanism underneath other exposure methods (Ingress controllers themselves are often exposed as a NodePort or LoadBalancer).

Step 4: LoadBalancer — Cloud-Provisioned External Access

apiVersion: v1
kind: Service
metadata:
  name: myapp-lb
spec:
  type: LoadBalancer
  selector:
    app: myapp
  ports:
    - port: 80
      targetPort: 8080
kubectl apply -f myapp-lb.yaml
kubectl get svc myapp-lb --watch
NAME        TYPE           CLUSTER-IP     EXTERNAL-IP     PORT(S)        AGE
myapp-lb    LoadBalancer   10.96.98.12    <pending>       80:31904/TCP   10s
myapp-lb    LoadBalancer   10.96.98.12    34.111.22.187   80:31904/TCP   45s

On AWS, GCP, or Azure, this automatically provisions a real cloud load balancer (an ELB, GCP Load Balancer, or Azure LB) pointing at your nodes. This is the simplest way to get real external access, but each LoadBalancer Service typically costs money and provisions its own separate infrastructure — which is exactly the problem Ingress solves when you have many services to expose (see below).

Cloud-specific annotations let you customize the underlying load balancer:

metadata:
  annotations:
    service.beta.kubernetes.io/aws-load-balancer-type: "nlb"
    service.beta.kubernetes.io/aws-load-balancer-internal: "true"

Step 5: Ingress — One Entry Point for Many Services

For exposing multiple HTTP(S) services without a separate LoadBalancer per service, use Ingress on top of ClusterIP Services (this requires an Ingress controller — see the dedicated guide on setting one up):

apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
  name: myapp-ingress
spec:
  ingressClassName: nginx
  rules:
    - host: myapp.example.com
      http:
        paths:
          - path: /
            pathType: Prefix
            backend:
              service:
                name: myapp-clusterip
                port:
                  number: 80
kubectl apply -f myapp-ingress.yaml
kubectl get ingress myapp-ingress

This gives you host- and path-based routing, TLS termination, and a single external IP shared across as many services as you need — far more cost-effective and manageable than one LoadBalancer per service.

Step 6: ExternalName — Mapping to an External Service

Sometimes you want in-cluster code to refer to an external dependency (a managed database, a third-party API) via internal Kubernetes DNS, without hardcoding the real hostname everywhere:

apiVersion: v1
kind: Service
metadata:
  name: external-db
spec:
  type: ExternalName
  externalName: prod-db.abc123.us-east-1.rds.amazonaws.com

Now in-cluster apps can just connect to external-db.default.svc.cluster.local, and if the real hostname ever changes, you update it in one place.

Step 7: Choosing the Right Type

TypeUse Case
ClusterIPInternal-only communication between services
NodePortQuick testing, on-prem without a cloud LB, underlying mechanism for controllers
LoadBalancerDirect external exposure of a single service, cloud environments
Ingress (+ ClusterIP)Many HTTP(S) services behind one external IP, host/path routing, TLS
ExternalNameReferencing external resources via internal DNS

Debugging “I Can’t Reach My Service”

kubectl get endpoints myapp-clusterip

Empty output here is the most common root cause — it means the Service’s selector doesn’t match any Pod’s labels:

NAME               ENDPOINTS   AGE
myapp-clusterip    <none>      2m
kubectl get pods --show-labels
kubectl describe svc myapp-clusterip

Compare the Service’s selector against the actual Pod labels — a mismatched key or value here is by far the most frequent cause of “the Service exists but nothing works.”

For external access issues specifically:

kubectl describe svc myapp-lb

Check for Events indicating the cloud provider failed to provision a load balancer (often an IAM permissions or quota issue), and confirm security groups/firewall rules allow inbound traffic on the exposed port.

Best Practices

Common Mistakes

Session Affinity for Stateful-ish Clients

Even in a mostly stateless architecture, some clients need to keep hitting the same backend Pod for the duration of a session (in-memory session state that hasn’t been externalized yet, for instance). A Service supports this without needing full application-level statefulness:

apiVersion: v1
kind: Service
metadata:
  name: myapp-clusterip
spec:
  type: ClusterIP
  selector:
    app: myapp
  sessionAffinity: ClientIP
  sessionAffinityConfig:
    clientIP:
      timeoutSeconds: 3600
  ports:
    - port: 80
      targetPort: 8080

sessionAffinity: ClientIP routes all traffic from a given client IP to the same backend Pod for up to timeoutSeconds. I’d treat this as a stopgap rather than a long-term architecture choice — it’s a much weaker guarantee than it sounds (multiple clients behind the same NAT/proxy IP will all land on one Pod, for example), and externalizing session state to Redis or similar is almost always the better long-term fix.

Multi-Port Services

Real applications often expose more than one port — an HTTP API alongside a separate metrics or admin port. A single Service can front multiple ports as long as each is named:

apiVersion: v1
kind: Service
metadata:
  name: myapp-multiport
spec:
  selector:
    app: myapp
  ports:
    - name: http
      port: 80
      targetPort: 8080
    - name: metrics
      port: 9090
      targetPort: 9090

Consumers then reference the specific named port they need, which is also required if you want a NetworkPolicy or ServiceMonitor to target one port without exposing intent about the others.

Headless Services for Direct Pod Addressing

Not every use case wants load-balanced access — sometimes you need to address individual Pods directly, most commonly for StatefulSet-backed workloads like databases or for client-side load balancing (gRPC clients often prefer resolving individual backend IPs themselves rather than going through a proxy VIP):

apiVersion: v1
kind: Service
metadata:
  name: myapp-headless
spec:
  clusterIP: None
  selector:
    app: myapp
  ports:
    - port: 8080

DNS lookups against a headless Service return every matching Pod’s IP directly, rather than a single virtual IP:

kubectl run debug --rm -it --image=busybox -- nslookup myapp-headless
Name:      myapp-headless
Address 1: 10.244.1.5
Address 2: 10.244.1.6
Address 3: 10.244.2.3

Exposing Non-HTTP Services

Ingress is HTTP/HTTPS-specific by design. For raw TCP or UDP services (a database, a game server, an MQTT broker) that need external exposure, you’re back to LoadBalancer or NodePort, or — if using NGINX Ingress specifically — its TCP/UDP ConfigMap-based passthrough feature, which sits somewhat outside the standard Ingress API:

apiVersion: v1
kind: ConfigMap
metadata:
  name: tcp-services
  namespace: ingress-nginx
data:
  "5432": "production/postgres-service:5432"

This is a reasonable stopgap for a handful of non-HTTP services, but for anything beyond that scale, a dedicated Layer 4 load balancer (a plain LoadBalancer Service per TCP service, or a purpose-built solution like MetalLB on bare metal) tends to be more maintainable than stretching an HTTP-oriented Ingress controller to cover it.

Troubleshooting External Connectivity Step by Step

When “it works internally but not from outside,” I work outward from the Pod:

# 1. Does the Pod itself respond?
kubectl exec -it <pod> -- curl -v http://localhost:8080/healthz

# 2. Does the ClusterIP Service route to it?
kubectl run debug --rm -it --image=busybox -- wget -qO- http://myapp-clusterip

# 3. Does the LoadBalancer/NodePort/Ingress route to the ClusterIP path?
curl -v http://<external-ip-or-hostname>/healthz

Each layer isolates a different class of problem — a failure at step 1 is an application bug, at step 2 a Service selector/endpoint issue, and at step 3 something in the external routing path (cloud load balancer health checks, security groups, DNS, or Ingress rule matching).

Summary

Exposing a Service in Kubernetes is a matter of choosing the right type for the audience: ClusterIP for internal-only traffic, NodePort for simple external access or as a building block, LoadBalancer for direct cloud-provisioned external access to a single service, and Ingress for consolidating many HTTP(S) services behind one smart, TLS-capable entry point. Get the Service’s label selector right, and 90% of “I can’t reach my app” problems disappear before they start.

References

Exit mobile version