NovuSpark
All articles
ContainerizationNovember 7, 2025 · NovuSpark Team

Kubernetes Networking: ClusterIP, NodePort, and Ingress

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.

ClusterIPinternal only — no external route at allNodePortraw port opened on every node — dev/test onlyLoadBalancerone real cloud LB per Service — costly at scaleIngressone LB, HTTP routing by host/path to many Services
Fig. 1 — four ways to expose a Service, from fully internal to one shared external entry point for many services

ClusterIP: the default, and internal-only

apiVersion: v1
kind: Service
metadata:
  name: web
spec:
  type: ClusterIP
  selector:
    app: web
  ports:
    - port: 80
      targetPort: 8080

ClusterIP (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: 30080

NodePort 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: 8080

On 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 web
NAME   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: 80

An 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: 80

cert-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: 5432

This 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: 5432
nslookup db-headless.default.svc.cluster.local
Name: 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: 3600

sessionAffinity: 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

  • ClusterIP is internal-only, by design — it's the correct default for most Services in a real cluster.
  • NodePort and LoadBalancer both 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.

Ready when you are

Want training built around your team's real work?

Tell us about your team and what you're trying to solve — we'll recommend a program that fits.