Kubernetes Service: NodePort
Expose a Service on a static port across every node. How NodePort layers on ClusterIP, the 30000-32767 range, externalTrafficPolicy, and when not to use it.
A NodePort Service opens the same port on every node in the cluster and forwards anything arriving there to the Service's Pods, including nodes running none of those Pods.
It is the simplest way to reach a Service from outside a cluster, and it is rarely the right way in production.
It builds on ClusterIP
NodePort does not replace ClusterIP; it adds to it. Creating a NodePort Service gives you all three of:
- A ClusterIP, working exactly as before for internal callers.
- A node port in the 30000 to 32767 range, open on every node.
- Forwarding from that port to the Service's endpoints.
apiVersion: v1
kind: Service
metadata:
name: payments-api
spec:
type: NodePort
selector:
app: payments-api
ports:
- port: 80 # the ClusterIP port
targetPort: 8080 # the Pod's port
nodePort: 30080 # the port on every node (optional)
kubectl get service payments-api
NAME TYPE CLUSTER-IP EXTERNAL-IP PORT(S) AGE
payments-api NodePort 10.96.41.203 <none> 80:30080/TCP 4s
80:30080/TCP reads as "ClusterIP port 80, node port 30080".
Omit nodePort and Kubernetes assigns a free one. Prefer that unless you need a fixed value. Hardcoding invites collisions, since a node port is cluster-wide and two Services cannot share one.
The three ports, again
This is where people lose track, because there are now three numbers:
| Field | Reached by | Example |
|---|---|---|
nodePort | External clients, via any node's IP | 30080 |
port | In-cluster clients, via the ClusterIP or DNS | 80 |
targetPort | The Service, forwarding to the Pod | 8080 |
So curl http://<node-ip>:30080 from outside and curl http://payments-api:80 from inside reach the same Pods on port 8080.
# Find a node IP to test against
kubectl get nodes -o wide
curl http://192.168.1.24:30080/healthz
On minikube there is a shortcut:
minikube service payments-api --url
Why the port range is what it is
30000 to 32767 avoids colliding with well-known ports and with anything the node's own OS is likely to use. You cannot expose a NodePort on 80 or 443 without changing an API server flag, which is not something to do casually.
That restriction is one of the main reasons NodePort is a poor production front door. Nobody wants to publish http://api.example.com:31847.
externalTrafficPolicy: the setting that matters
By default (Cluster), traffic arriving at any node is forwarded to a Pod on any node. That gives even distribution and means every node is a valid entry point, but the extra hop source-NATs the packet, so your application sees the node's IP rather than the client's.
spec:
externalTrafficPolicy: Local
With Local, a node only forwards to Pods running on itself. The client IP is preserved and the extra hop disappears, at the cost of two things: nodes with no matching Pod drop the traffic, and load becomes uneven if Pods are unevenly distributed.
Cluster (default) | Local | |
|---|---|---|
| Client source IP | Lost (SNAT) | Preserved |
| Extra network hop | Possible | Never |
| Nodes without a Pod | Forward anyway | Drop the traffic |
| Load distribution | Even | Follows Pod placement |
If you need real client IPs, for rate limiting, geolocation, allowlisting, or audit logs, use Local and make sure something in front only sends traffic to nodes that have Pods. Cloud load balancers do this automatically via health checks, which is exactly why Local is common on LoadBalancer Services.
Why it is a poor production choice
Every node opens the port, including nodes running none of the Service's Pods. On a 50-node cluster that is 50 open ports for one application.
You still need something in front. Nodes come and go, so clients cannot target a node IP directly. You need a load balancer, which is what a LoadBalancer Service creates for you automatically.
The port range is unusable for a public URL.
No TLS, no host or path routing. That is what an Ingress provides.
Where NodePort genuinely fits:
- Local development on minikube or kind.
- Behind an external load balancer you manage yourself, particularly on bare metal.
- Non-HTTP protocols where an Ingress does not apply and you have your own load balancer.
For anything public and HTTP, use an Ingress. For non-HTTP traffic on a cloud provider, use LoadBalancer.
Debugging
Work outward from the Pods.
# 1. Does the Service have endpoints at all?
kubectl get endpointslices -l kubernetes.io/service-name=payments-api
# 2. Does it work internally?
kubectl run tmp --rm -it --image=nicolaka/netshoot -- curl -s payments-api/healthz
# 3. What is the assigned node port?
kubectl get svc payments-api -o jsonpath='{.spec.ports[0].nodePort}'
# 4. Can you reach the node at all?
kubectl get nodes -o wide
If it works internally but not from outside, the Service is fine and the problem is network access to the node. On a cloud provider, the node's security group or firewall rule almost certainly does not allow inbound traffic on the 30000 to 32767 range, that is the single most common cause. Check too whether you are using a private node IP from outside the VPC.
Quick reference
| Task | Command |
|---|---|
| Create from a Deployment | kubectl expose deployment <n> --type=NodePort --port=80 --target-port=8080 |
| Find the node port | kubectl get svc <n> -o jsonpath='{.spec.ports[0].nodePort}' |
| List node IPs | kubectl get nodes -o wide |
| URL on minikube | minikube service <n> --url |
| Check endpoints | kubectl get endpointslices -l kubernetes.io/service-name=<n> |
| Detail | kubectl describe svc <n> |
Key takeaways
- NodePort includes a ClusterIP. It adds external access rather than replacing internal access.
- The port opens on every node, whether or not that node runs a matching Pod.
- The range is 30000 to 32767, which rules it out as a public URL.
- Three ports now:
nodePortoutside,portinside,targetPorton the Pod. externalTrafficPolicy: Clusterhides the client IP. UseLocalto preserve it, accepting uneven load.- Works internally but not externally? Check the node firewall or security group on the NodePort range.
- Use Ingress for public HTTP and LoadBalancer for non-HTTP on a cloud provider.
Check your understanding
Every request from the same IP
An application behind a NodePort Service logs the same handful of source IPs for all traffic, matching the cluster nodes. Rate limiting by client IP is therefore useless. Why?
Unreachable from outside
A NodePort Service works when curled from inside the cluster but times out from a laptop using a node's public IP and the node port. What should you check first?
Frequently Asked Questions
What is a NodePort Service and how does it work?
It exposes a Service on a static port, between 30000 and 32767, on every node in the cluster. Traffic arriving at any node's IP on that port is forwarded to the Service's Pods, even if that particular node runs none of them. A NodePort Service also includes a ClusterIP, so internal callers are unaffected. NodePort adds an external path rather than replacing the internal one.
Why is the NodePort range limited to 30000-32767?
The range is chosen to avoid colliding with well-known service ports and with ports the node's own operating system may use. Exposing a Service on 80 or 443 directly would require changing the --service-node-port-range flag on the API server, which is not something to do casually. This restriction is a large part of why NodePort is unsuitable as a public entry point. Nobody wants to publish a URL with port 31847 in it.
What is the difference between NodePort and LoadBalancer?
LoadBalancer builds on NodePort. It does everything NodePort does and additionally asks the cloud provider to create a real external load balancer that distributes traffic across the nodes and gives you a stable public address. NodePort leaves you to solve that yourself, which is fine on bare metal where you already run a load balancer and awkward on a cloud provider where LoadBalancer does it in one line.
Should I use NodePort in production?
Usually not. Every node opens the port regardless of whether it runs the workload, the port range is unusable for a public URL, and you still need something in front to spread traffic across nodes and handle nodes coming and going. For public HTTP traffic use an Ingress, which gives you hostname and path routing plus TLS through a single load balancer. NodePort is genuinely useful for local development with minikube or kind, and on bare metal behind a load balancer you manage yourself.
Why does my application see the node's IP instead of the client's?
Because externalTrafficPolicy defaults to Cluster, which allows a node to forward traffic to a Pod on another node, and that second hop requires source NAT, replacing the client address. Setting externalTrafficPolicy: Local keeps traffic on the receiving node and preserves the original source IP. The trade-off is that nodes with no matching Pod drop traffic rather than forwarding it, so whatever sits in front must health-check and only send traffic to nodes that have Pods.
My NodePort Service works inside the cluster but not from outside. What is wrong?
If it works internally, the Service, its selector and its endpoints are all correct, so the problem is network reachability to the node. On a cloud provider the node's security group or firewall almost certainly does not allow inbound traffic on 30000 to 32767, which is the most frequent cause. Also confirm you are using an IP that is actually reachable from where you are testing. A private node IP will not work from outside the VPC.