The previous lesson covered what a Service is — a stable virtual IP and DNS name in front of a set of pods, wired up by a label selector. What it didn't cover is that every Service has a type, and the type determines who can reach it and from where. There are four: ClusterIP, NodePort, LoadBalancer, and ExternalName. This lesson walks through each one declaratively against the same test Deployment, including the two problems everyone hits on a local cluster — a NodePort you can't reach from your browser, and a LoadBalancer whose EXTERNAL-IP never leaves <pending>.
Setup: A Deployment to Expose
All four examples point at the same nginx Deployment with three replicas:
# deployment.yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: nginx-deployment
spec:
replicas: 3
selector:
matchLabels:
app: nginx
template:
metadata:
labels:
app: nginx
spec:
containers:
- name: nginx
image: nginx
ports:
- containerPort: 80kubectl apply -f deployment.yaml
kubectl get deployment
kubectl get pods -o wideNote the pod IPs in the output (10.244.0.2, 10.244.0.3, 10.244.0.4, ...). Now delete one pod and check again:
kubectl delete pod <pod-name>
kubectl get pods -o wideThe replacement pod comes back with a different IP (say 10.244.0.8). This is the churn a Service absorbs: every Service type below keeps a stable address in front of these pods, no matter how often they're replaced. The selector that ties them together is the app=nginx label — verify with kubectl get pods --show-labels.
ClusterIP: Internal-Only (the Default)
ClusterIP is what you get when you don't specify a type. It allocates a virtual IP that is reachable only from inside the cluster — ideal for internal APIs, databases, and microservice-to-microservice traffic that should never be exposed externally.
# clusterip-service.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: ClusterIP
selector:
app: nginx
ports:
- port: 80
targetPort: 80kubectl apply -f clusterip-service.yaml
kubectl get svcNAME TYPE CLUSTER-IP
nginx-service ClusterIP 10.96.124.21
To prove it works, test from inside the cluster with a throwaway pod:
kubectl run test-pod --image=busybox --restart=Never -it --rm# inside the pod
wget -qO- http://nginx-serviceYou get the "Welcome to nginx!" page back — resolved via the Service's DNS name, load-balanced across the three pods. If this fails instead, check kubectl get endpoints nginx-service; an empty endpoints list means the Service selector doesn't match your pod labels.
Now try opening http://10.96.124.21 from your laptop's browser: not accessible. That's not a bug — the 10.96.x.x range only exists inside the cluster. ClusterIP is internal by design.
NodePort: Opening a Port on Every Node
NodePort builds on ClusterIP and additionally opens a static port (from the range 30000–32767) on every node in the cluster. Traffic hitting <node-ip>:<node-port> is forwarded to the Service and on to the pods.
Remove the previous Service and apply the NodePort version:
kubectl delete -f clusterip-service.yaml# nodeport-service.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: NodePort
selector:
app: nginx
ports:
- port: 80
targetPort: 80
nodePort: 30080kubectl apply -f nodeport-service.yaml
kubectl get svcNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
nginx-service NodePort 10.96.32.210 <none> 80:30080/TCP
Why NodeIP:30080 Doesn't Respond on Kind
This is where nearly everyone gets stuck, so let's walk through it. Two separate mistakes are common:
Mistake 1: browsing to the ClusterIP. In the output above, 10.96.32.210 is the ClusterIP, not a node IP. A NodePort is exposed on the node's IP address — http://10.96.32.210:30080 will never work from your browser, because 10.96.x.x is only routable inside the cluster.
Mistake 2: expecting the Docker container's IP to be reachable. On Kind, your "node" is a Docker container. You can find its IP:
docker ps # find the control-plane container name
docker inspect service-demo-control-plane \
--format '{{range.NetworkSettings.Networks}}{{.IPAddress}}{{end}}'That returns something like 172.19.0.4 — the container's private IP on Docker's bridge network. On Linux, http://172.19.0.4:30080 may work directly. On macOS and Windows, Docker Desktop runs containers inside a lightweight VM, so the bridge network exists inside that VM and your host browser can't route to it:
| Address | From your host browser | Reason |
|---|---|---|
http://localhost:30080 | Works (with port mapping) | Docker forwards the host port to the container |
http://172.19.0.4:30080 | Fails on macOS/Windows | Private IP inside Docker's VM |
http://10.96.32.210:30080 | Fails everywhere | ClusterIP — only reachable inside the cluster |
The fix on Kind is to publish the NodePort from the container to your host when creating the cluster:
# kind.yaml
kind: Cluster
apiVersion: kind.x-k8s.io/v1alpha4
nodes:
- role: control-plane
extraPortMappings:
- containerPort: 30080
hostPort: 30080
protocol: TCPkind delete cluster --name service-demo
kind create cluster --name service-demo --config kind.yamldocker ps should now show 0.0.0.0:30080->30080/tcp, and http://localhost:30080 serves the nginx page. The request path is: browser → localhost:30080 → Docker port mapping → Kind node container → NodePort Service → pods.
If you don't specifically need to demonstrate NodePort, kubectl port-forward is the quicker alternative for demos — no cluster recreation required:
kubectl port-forward svc/nginx-service 8080:80
# then open http://localhost:8080The same "port isn't reachable" gotcha applies in the cloud. If your cluster runs on VMs (for example, Kind inside an EC2 instance), http://<EC2-PUBLIC-IP>:30080 only works when the port is actually published to the host and the instance's security group / firewall allows inbound traffic on that NodePort. When a NodePort mysteriously times out, check the path hop by hop: is the port mapped, and is the firewall open?
LoadBalancer: A Real External Endpoint
NodePort works, but asking users to hit <some-node-ip>:30080 isn't production-grade. LoadBalancer builds on NodePort and asks the cluster's cloud controller manager to provision an actual external load balancer — an AWS ELB/NLB on EKS, a Google Cloud LB on GKE — with a stable public IP or DNS name.
kubectl delete -f nodeport-service.yaml# loadbalancer-service.yaml
apiVersion: v1
kind: Service
metadata:
name: nginx-service
spec:
type: LoadBalancer
selector:
app: nginx
ports:
- port: 80
targetPort: 80In a real cloud environment you'll often tune the provisioned load balancer through annotations — for example, requesting an AWS Network Load Balancer:
apiVersion: v1
kind: Service
metadata:
name: my-app-lb
annotations:
service.beta.kubernetes.io/aws-load-balancer-type: nlb
spec:
selector:
app: my-app
ports:
- port: 443
targetPort: 3000
type: LoadBalancerkubectl apply -f loadbalancer-service.yaml
kubectl get svcOn a managed cloud cluster, EXTERNAL-IP fills in with a real address after a minute or two. On Kind, you'll see this instead — indefinitely:
NAME TYPE EXTERNAL-IP
nginx-service LoadBalancer <pending>
Why EXTERNAL-IP Stays <pending> on Local Clusters
This is expected, not a bug. A LoadBalancer Service works by asking the cloud controller manager to provision an external load balancer. On EKS, that request flows through the AWS cloud controller manager to the AWS API, which creates an ELB/NLB and returns its DNS name. On Kind (or minikube, k3d, bare-metal kubeadm) there is no cloud provider to call — the Service is created successfully, but Kubernetes is waiting forever for someone to supply an external IP that never arrives.
To get a working EXTERNAL-IP on a local or bare-metal cluster, you need software that plays the cloud provider's role. The standard choice is MetalLB: it watches for pending LoadBalancer Services and assigns each one an IP from an address pool you configure (on Kind, a range carved out of the Docker network subnet). Once MetalLB is running, the same kubectl get svc shows a real address like 172.19.255.200 instead of <pending>.
Installing and configuring MetalLB (its controller and speaker components, IPAddressPool, L2Advertisement) is covered in a dedicated Day-2 lesson. For everyday local development, don't fight this — NodePort or kubectl port-forward get you to a working URL much faster than emulating a cloud load balancer.
ExternalName: A DNS Alias for the Outside World
ExternalName is the odd one out: it doesn't route traffic to pods at all. It has no selector and no endpoints — it simply maps a Service name inside the cluster to an external DNS name, returned as a DNS alias (CNAME).
# externalname-service.yaml
apiVersion: v1
kind: Service
metadata:
name: google-service
spec:
type: ExternalName
externalName: google.comkubectl apply -f externalname-service.yamlApplications inside the cluster can now use the stable in-cluster name:
google-service.default.svc.cluster.local
which resolves to google.com. The typical use case is an external API or a managed database outside the cluster: your pods talk to a consistent Kubernetes Service name, and if the external endpoint ever moves, you update one Service manifest instead of every consumer.
Comparing the Four Types
The first three types stack: LoadBalancer builds on NodePort, which builds on ClusterIP — external traffic flows LoadBalancer → NodePort → ClusterIP → pods. ExternalName stands apart as pure DNS.
| Service Type | Internal Access | External Access | Cloud Required | Common Use Case |
|---|---|---|---|---|
ClusterIP | Yes | No | No | Internal APIs, databases, microservices |
NodePort | Yes | Yes — NodeIP:port (30000–32767) | No | Development and testing |
LoadBalancer | Yes | Yes — dedicated IP / DNS name | Yes (or MetalLB) | Production applications |
ExternalName | DNS name only | Depends on the target | No | External APIs or databases |
Where Each Type Fits: Production Architecture
You might expect production clusters to create one LoadBalancer Service per application — but that would mean one (billable) cloud load balancer per service. Real architectures put a single load balancer in front of an Ingress controller, and everything behind it stays ClusterIP:
Internet
│
AWS Load Balancer
│
Ingress Controller
│
┌───────────────┴───────────────┐
│ │
frontend-service backend-service
(ClusterIP) (ClusterIP)
│ │
Frontend Pods Backend Pods
│
database-service
(ClusterIP)
│
Database Pods
Putting Services together with the workload objects from earlier lessons, a request into a production cluster travels through a consistent chain of layers:
- Ingress — routes external HTTP(S) traffic to the right Service based on hostname/path.
- Service — provides a stable IP/DNS name and load-balances across healthy pods.
- Deployment — declares the desired state (image, replica count) for the workload.
- ReplicaSet — managed by the Deployment; keeps the requested number of pods running.
- Pods — the scheduled units that actually run on nodes.
- Containers — the running processes inside each pod that handle the request.
Ingress → Service → Deployment → ReplicaSet → Pods → Containers
Each layer only needs to know about the one below it — Ingress talks to a Service name, a Service talks to pods via label selectors, and a Deployment/ReplicaSet only cares about keeping pods alive. That decoupling is what lets pods churn (crash, reschedule, roll out a new version) without anything upstream needing to change.
Restricting Traffic: Network Policies
Services make pods reachable; Network Policies do the opposite — they restrict which pods are allowed to talk to each other. By default, any pod can reach any other pod in the cluster. In production you usually want the database reachable only from the backend, and the backend reachable only from the frontend:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: api-network-policy
namespace: production
spec:
podSelector:
matchLabels:
app: api
policyTypes:
- Ingress
- Egress
ingress:
- from:
- podSelector:
matchLabels:
app: frontend
ports:
- protocol: TCP
port: 3000
egress:
- to:
- podSelector:
matchLabels:
app: postgres
ports:
- protocol: TCP
port: 5432This policy locks down the api pods from both directions: inbound traffic is allowed only from frontend pods on port 3000, and outbound traffic only to postgres pods on port 5432. Like Services, Network Policies select pods by label — the same mechanism, used to permit rather than to route.
Key Takeaways
- Pod IPs are ephemeral; every Service type provides a stable address in front of them.
- ClusterIP (the default) is for internal traffic only — most Services in a production cluster are ClusterIP.
- NodePort opens a static port (30000–32767) on every node; on Kind you must publish that port to the host (
extraPortMappings) or usekubectl port-forward. - LoadBalancer provisions a real cloud load balancer; on local clusters it stays
<pending>until something like MetalLB fills the cloud provider's role. - ExternalName is a DNS alias to an endpoint outside the cluster — no selector, no endpoints.
- In production, one load balancer fronts an Ingress controller and everything behind it stays ClusterIP; Network Policies then restrict which pods may talk to each other.