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
| Type | Use Case |
|---|---|
| ClusterIP | Internal-only communication between services |
| NodePort | Quick testing, on-prem without a cloud LB, underlying mechanism for controllers |
| LoadBalancer | Direct external exposure of a single service, cloud environments |
| Ingress (+ ClusterIP) | Many HTTP(S) services behind one external IP, host/path routing, TLS |
| ExternalName | Referencing 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
- Default to
ClusterIPfor anything that doesn’t need to be reachable from outside the cluster. - Use Ingress instead of multiple
LoadBalancerServices once you have more than one or two HTTP services to expose — it’s dramatically cheaper and easier to manage TLS centrally. - Always name container ports explicitly in your Pod spec and reference them by name in the Service, avoiding fragile numeric coupling.
- Set
externalTrafficPolicy: LocalonLoadBalancer/NodePortServices when you need to preserve the client’s real source IP for logging or rate limiting, understanding the tradeoff that it can cause uneven load distribution across nodes.
Common Mistakes
- Forgetting
targetPortdoesn’t have to matchport— assuming they’re always identical and getting confused when nothing connects. - Not checking
kubectl get endpointsfirst when a Service “isn’t working” — this single command usually reveals the problem immediately. - Provisioning a
LoadBalancerper microservice instead of consolidating behind Ingress, running up unnecessary cloud costs. - Exposing internal services (databases, admin panels) via
LoadBalancerorNodePortby mistake, unintentionally making them internet-reachable.
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.
