This is the second post in our Kubernetes fundamentals series. Start with Pods, Deployments, and Services if you're joining partway through.
The previous post in this series introduced Services as a stable address for a set of Pods, without covering the detail that actually matters once you need traffic from outside the cluster to reach one: a Service has a type, and the default type is deliberately not reachable from outside the cluster at all.
ClusterIP: the default, and internal-only
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: ClusterIP
selector:
app: web
ports:
- port: 80
targetPort: 8080ClusterIP (the default if type is omitted entirely) gives a Service a stable IP address that's only routable from within the cluster. This is correct, and exactly what you want, for the large majority of Services in a real system — an internal API, a database, anything that only other things inside the cluster should ever talk to directly.
NodePort: opening a specific port on every node
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: NodePort
selector:
app: web
ports:
- port: 80
targetPort: 8080
nodePort: 30080NodePort opens a specific port (30000–32767 by convention) on every node in the cluster, forwarding traffic on that port to the Service. <any-node-ip>:30080 reaches this Service from outside the cluster, from any node.
This is genuinely useful for local development and quick testing, and genuinely awkward for real production traffic: it exposes a raw port directly on every node, ties clients to knowing a specific node's IP (which is exactly the kind of "stable address for something that changes underneath it" problem Services exist to solve — NodePort partially reintroduces it at the node level), and provides no HTTP-level routing at all.
LoadBalancer: asking the cloud provider for a real one
apiVersion: v1
kind: Service
metadata:
name: web
spec:
type: LoadBalancer
selector:
app: web
ports:
- port: 80
targetPort: 8080On a cloud-managed cluster (EKS, GKE, AKS), type: LoadBalancer provisions an actual cloud load balancer (an AWS Network Load Balancer, for EKS) pointed at the Service, with its own stable external IP or DNS name:
kubectl get service webNAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S)
web LoadBalancer 10.100.34.12 a1b2c3d4.elb.eu-west-2.amazonaws.com 80:31234/TCP
The real limitation: one cloud load balancer per Service, each with its own genuinely non-trivial cost. A cluster running twenty separate HTTP services, each with its own LoadBalancer Service, means twenty separate load balancers billed independently — appropriate for a handful of genuinely distinct entry points, expensive and unnecessary as the default way to expose every HTTP service in a cluster.
Ingress: HTTP-aware routing, one entry point for many services
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
spec:
rules:
- host: app.example.com
http:
paths:
- path: /api
pathType: Prefix
backend:
service:
name: api-service
port:
number: 80
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80An Ingress routes HTTP(S) traffic to different Services based on hostname and path — app.example.com/api to one Service, everything else to another — through a single load balancer, regardless of how many Services it fronts. This is the mechanism that actually scales: adding a tenth internal service means adding a rule to the Ingress, not provisioning a tenth cloud load balancer.
An Ingress resource on its own does nothing — it requires an Ingress Controller (nginx-ingress and AWS Load Balancer Controller are common choices) actually running in the cluster to read Ingress objects and configure real routing accordingly. This is a detail that trips people up constantly: creating an Ingress object with no controller installed produces no error and no routing — just a resource sitting there, doing nothing, because nothing is watching it.
TLS termination at the Ingress
Beyond routing, Ingress is also the usual place HTTPS termination happens, so individual Services and Pods don't each need to handle certificates themselves:
apiVersion: networking.k8s.io/v1
kind: Ingress
metadata:
name: main-ingress
annotations:
cert-manager.io/cluster-issuer: letsencrypt-prod
spec:
tls:
- hosts:
- app.example.com
secretName: app-tls-cert
rules:
- host: app.example.com
http:
paths:
- path: /
pathType: Prefix
backend:
service:
name: web-service
port:
number: 80cert-manager (a common companion tool, referenced here via the cluster-issuer annotation) automates requesting and renewing a real TLS certificate from Let's Encrypt, storing it in the Kubernetes Secret named by secretName — the Ingress Controller reads that Secret and terminates TLS at the cluster edge, so traffic between the Ingress and internal Services can stay plain HTTP within the (already-isolated) cluster network. This is genuinely the standard production pattern: certificates managed automatically and renewed without manual intervention, rather than each team handling its own certificate lifecycle.
Network Policies: restricting traffic between Pods
Everything covered so far controls how traffic gets into the cluster. By default, Kubernetes allows any Pod to talk to any other Pod within the cluster — no internal segmentation at all unless you explicitly add it. A NetworkPolicy restricts this:
apiVersion: networking.k8s.io/v1
kind: NetworkPolicy
metadata:
name: db-allow-from-api-only
spec:
podSelector:
matchLabels:
app: db
policyTypes:
- Ingress
ingress:
- from:
- podSelector:
matchLabels:
app: api
ports:
- port: 5432This policy means Pods labeled app: db accept incoming connections only from Pods labeled app: api, on port 5432 — everything else is denied by default once any NetworkPolicy selects a Pod. This is the Kubernetes-native equivalent of the Docker network-isolation pattern covered in our Docker networking post: keeping a database reachable only from the specific service that's meant to talk to it, rather than from anything else that happens to be running in the same cluster. Like Ingress, NetworkPolicy objects require a compatible CNI plugin (Calico and Cilium are common choices) actually enforcing them — not every cluster networking setup does, by default.
A rough guide for choosing
- ClusterIP: the default for anything that's only ever called from inside the cluster — which, in most real systems, is most Services.
- NodePort: local development and quick manual testing, rarely appropriate for real production traffic.
- LoadBalancer: a genuinely distinct external entry point that needs its own dedicated load balancer — a handful per cluster, not one per Service.
- Ingress: the standard way to expose multiple HTTP services externally through one shared entry point, and almost always the right choice once there's more than one or two external-facing HTTP services.
Headless Services: bypassing load balancing entirely
Every Service covered so far load-balances across matching Pods behind one stable IP. A headless Service (clusterIP: None) instead returns the individual IPs of every matching Pod directly via DNS, without any load balancing at all:
apiVersion: v1
kind: Service
metadata:
name: db-headless
spec:
clusterIP: None
selector:
app: db
ports:
- port: 5432nslookup db-headless.default.svc.cluster.localName: db-headless.default.svc.cluster.local
Address: 10.244.1.7
Address: 10.244.1.8
Address: 10.244.1.9
This is the mechanism underneath a StatefulSet (covered in our storage post later in this series) needing to address each replica individually by its own stable identity, rather than through a single load-balanced endpoint — a client that specifically needs to reach db-0 and not "whichever database replica happens to be selected" needs exactly this kind of direct, per-Pod DNS resolution, which an ordinary Service's load-balancing behavior would otherwise hide.
Session affinity: routing a client back to the same Pod
A Service load-balances every request independently by default, which is a problem for anything relying on server-side session state tied to a specific Pod:
apiVersion: v1
kind: Service
metadata:
name: web
spec:
selector:
app: web
sessionAffinity: ClientIP
sessionAffinityConfig:
clientIP:
timeoutSeconds: 3600sessionAffinity: ClientIP routes requests from the same client IP to the same backing Pod for the configured timeout window — a pragmatic fix for an application that hasn't yet externalized session state (the same problem covered for Docker networking and multi-instance deployments earlier in this blog), though moving session state to a shared store like Redis remains the more robust long-term fix, since IP-based affinity breaks the moment a client's IP genuinely changes mid-session.
What to actually remember from this post
ClusterIPis internal-only, by design — it's the correct default for most Services in a real cluster.NodePortandLoadBalancerboth expose a Service externally, but at genuinely different cost and abstraction levels — one raw port per node, versus one real cloud load balancer per Service.- Ingress is the HTTP-aware routing layer that lets many Services share a single external entry point, and is also the usual place TLS termination happens via a tool like cert-manager.
- An Ingress Controller must actually be installed for Ingress objects to do anything at all — the object alone is inert.
- NetworkPolicies restrict Pod-to-Pod traffic, which is unrestricted by default — the cluster-internal equivalent of the network-isolation practices covered for plain Docker.
Next in the series: ConfigMaps, Secrets, and Environment Configuration in Kubernetes, where we cover how configuration and secrets actually get into a running Pod.
