Headless Services: talking to one specific Pod
A normal Service hides its Pods behind one virtual IP and picks a Pod for you. Usually that is exactly what you want. But a database primary, a Kafka broker, or a peer in a cluster must be reached by name, individually. A headless Service gives every Pod its own DNS name instead.
When "any Pod" is not good enough
Fine with a normal Service
posts, comments, query: every copy is identical and stateless. Any Pod can answer any request.
Needs a specific Pod
- A database: writes must go to the primary, reads may go to replicas.
- Kafka, Cassandra, Redis Cluster, etcd: members must find each other by name.
- gRPC clients that want every Pod address to balance requests themselves.
What headless means
One line: clusterIP: None. The Service gets no virtual IP, kube-proxy does nothing for it, and nothing load-balances. It only does one job: DNS.
What DNS returns
$ nslookup db
Name: db.default.svc.cluster.local
Address: 10.96.5.9 ← one virtual IPOne address. Which Pod you reach is decided per connection by kube-proxy.
$ nslookup db-headless
Name: db-headless.default.svc.cluster.local
Address: 10.244.1.21 ← db-0
Address: 10.244.2.14 ← db-1
Address: 10.244.3.8 ← db-2Every ready Pod's real IP. Your client decides what to do with them.
$ nslookup db-0.db-headless
Name: db-0.db-headless.default.svc.cluster.local
Address: 10.244.1.21 ← exactly this PodWith a StatefulSet, each Pod also gets its own name: <pod>.<service>. This is the part databases need.
Headless + StatefulSet = stable names
A Deployment's Pods get random names (db-7d9f8-x2kqp) that change on every replacement, so per-Pod DNS would be useless. A StatefulSet (chapter 38 goes deeper) is a controller like a Deployment, but for stateful apps: its Pods are numbered, and a replacement Pod gets the same name (and, in chapter 31, the same disk). Step through:
db-0 Running db-0.db-headless → 10.244.1.21Pods start one at a time, in order. db-0 first. It becomes the primary.
db-0 Running db-0.db-headless → 10.244.1.21 db-1 Running db-1.db-headless → 10.244.2.14 db-2 Running db-2.db-headless → 10.244.3.8db-1 and db-2 start after it and connect to
db-0.db-headlessto replicate.db-1 ✗ node-2 diedA Pod is lost.
db-1 Running db-1.db-headless → 10.244.3.30 ← new IP, same nameThe StatefulSet recreates db-1, not a random name. DNS updates to the new IP. Anyone using the name
db-1.db-headlessstill finds it.
The YAML
apiVersion: v1
kind: Service
metadata:
name: db-headless
spec:
clusterIP: None
selector:
app: db
ports:
- port: 5432
---
apiVersion: apps/v1
kind: StatefulSet
metadata:
name: db
spec:
serviceName: db-headless
replicas: 3
selector:
matchLabels:
app: db
template:
metadata:
labels:
app: db
spec:
containers:
- name: postgres
image: postgres:16
ports:
- containerPort: 5432
# volumeClaimTemplates: ... (chapter 31)
clusterIP: None
The one line that makes it headless. Everything else is a normal Service: a selector and ports.
serviceName
Links the StatefulSet to the headless Service. This is what creates the per-Pod names db-0.db-headless, db-1.db-headless…
volumeClaimTemplates
Gives every Pod its own disk that follows it, so db-1 always gets db-1's data. Covered with storage.
Often you add a second, normal Service for the same Pods: a ClusterIP db-read that spreads read queries over all replicas, while writes use db-0.db-headless.
Using it from Node
// writes: always the primary
const primary = new Pool({ host: 'db-0.db-headless', port: 5432 });
// reads: any replica (a normal ClusterIP Service in front of all of them)
const replicas = new Pool({ host: 'db-read', port: 5432 });
// or: get every Pod address yourself
const { resolve4 } = require('node:dns/promises');
const ips = await resolve4('db-headless.default.svc.cluster.local'); // ['10.244.1.21', ...]
ClusterIP vs headless
| ClusterIP (normal) | Headless | |
|---|---|---|
clusterIP | A virtual IP | None |
| DNS answers | The virtual IP | All ready Pod IPs |
| Load balancing | Yes, by kube-proxy | No; the client decides |
| Per-Pod names | No | Yes, with a StatefulSet |
| Use for | Stateless apps | Databases, clustered systems, client-side balancing |
Good to know
- Only ready Pods appear in DNS. Peers that must find each other before they are ready (cluster bootstrapping) need
publishNotReadyAddresses: true. - DNS caching: clients that resolve once at startup keep stale IPs. Prefer per-Pod names, and reconnect on errors.
- You usually do not write this yourself: database Helm charts and operators create the headless Service and StatefulSet for you. Knowing the pattern lets you read and debug them.
Quick check
curl http://db-headless:5432 and expect load balancing like a normal Service?Show answer
No. There is no virtual IP and no kube-proxy rule. The client gets a list of Pod IPs and, depending on the library, usually just uses the first. Use a normal Service when you want balancing.
Headless Services: common questions
What is a headless Service in Kubernetes?
A Service with clusterIP: None. It has no virtual IP and no load balancing; DNS returns the IPs of all ready Pods, and with a StatefulSet each Pod gets its own DNS name.