Internal DNS for Self-Hosted Docker Clusters Without Kubernetes
How Temps provides stable service discovery and failover-aware DNS for self-hosted Docker clusters without operating Kubernetes or CoreDNS.
Temps Team
May 17, 2026 · 2mo ago
How Temps provides stable service discovery and failover-aware DNS for self-hosted Docker clusters without operating Kubernetes or CoreDNS.
Temps Team
May 17, 2026 · 2mo ago
Temps v0.1.0 ships built-in internal DNS for self-hosted Docker clusters without Kubernetes — a per-node Hickory resolver embedded in temps-agent that gives every managed database a stable *.temps.local hostname, eliminates the IP-env-var churn that broke Postgres HA before v0.1.0, and keeps resolving from a fsynced on-disk snapshot if the control plane is unreachable.
If you run a self-hosted Docker cluster and need service names that survive pod moves and failovers — without standing up Kubernetes — Temps v0.1.0 includes this out of the box. No extra process to run. No CoreDNS Deployment to manage.
The short answer: deploy Temps. When you run temps serve and join worker nodes with temps join <url> <token>, each worker's temps-agent automatically starts an embedded Hickory DNS resolver. Every container gets the bridge gateway IP injected as its first nameserver via Docker's --dns flag. Names like pg-orders.temps.local resolve from any node, sub-millisecond, with no separate DNS server to operate.
Three Verified Temps Claims:
Per-node full-zone authority, 1-second poll cadence: Every temps-agent embeds a Hickory DNS resolver and serves the complete *.temps.local zone from a local snapshot — not just its own records. The poll interval defaults to 1 second; the control plane returns immediately on changes (no server-side hold), so propagation is near-real-time. (Source: crates/temps-dns-resolver/src/config.rs)
5-second failover to DNS flip: A 5-second-tick reconciler in temps-providers queries pg_auto_failover, upserts primary.<svc>.temps.local (TTL 5s) and replica.<svc>.temps.local (TTL 30s), and rotates the A record on promotion. libpq with target_session_attrs=read-write reconnects transparently. (Source: ADR-011)
Control-plane-outage resilience from on-disk snapshot: The resolver fsyncs zone.json on every applied generation to /var/lib/temps/dns/. If the control plane is unreachable, containers keep resolving from this snapshot. node_dns_state.health flips to 'stale' so drift is visible; existing app connections are unaffected.
Comparison: Temps internal DNS vs Kubernetes CoreDNS vs Manual IP Management
| Temps v0.1.0 | Kubernetes CoreDNS | Manual env-var IPs | |
|---|---|---|---|
| Requires Kubernetes | No | Yes | No |
| DNS resolver topology | Per-node (embedded in agent) | Centralized Deployment (2+ pods) | N/A |
| Lookup latency | Sub-millisecond (localhost) | 1–5ms (network hop) | N/A |
| Default sync cadence | 1-second poll | Watch events (milliseconds) | Manual redeploy |
| Failover propagation | ~5–7 seconds | Milliseconds (watch events) | Manual redeploy required |
| Control plane outage | Serves stale zone from disk | Depends on NodeLocal DNSCache | No impact |
| App-to-app DNS | Not in v0.1.0 | Yes (all services) | No |
| Setup complexity | Zero config — included in temps-agent | Cluster DNS infra required | Manual maintenance |
| License | Apache 2.0 | Apache 2.0 | N/A |
TL;DR: Temps v0.1.0 ships an internal DNS plane (
*.temps.local) backed by a per-node Hickory resolver, with three tiers of records: container-specific (pg-orders-0.pg-orders.temps.local), service VIP (pg-orders.temps.local), and role-aliased (primary.pg-orders.temps.local). The architecture borrows ideas from Kubernetes CoreDNS but is scoped narrowly to managed-database HA. App-to-app service discovery, DNSSEC, IPv6, and headless-service-style pod DNS are not in this release.
If you've worked with Kubernetes, you know how DNS feels in production: my-service.my-namespace.svc.cluster.local, headless services, ExternalName, kube-dns vs CoreDNS, the occasional "why is this resolving to the old pod" debugging session at 2am. It's powerful and complicated.
Temps v0.1.0 ships an internal DNS plane that does roughly the same job — a stable name that resolves to the right container IP from anywhere in the cluster — but with a deliberately narrower scope and a different set of design choices. This post is an honest comparison: what's supported, what isn't, and where the trade-offs land.
Before v0.1.0, a Postgres HA cluster on Temps had a structural bug. The cluster_connection_string() function built a libpq multi-host string from each member's hostname:
// Before v0.1.0 — broken end-to-end on multi-node clusters
let hosts = members.iter()
.map(|m| m.hostname.unwrap_or_else(|| m.container_name.clone()))
.collect::<Vec<_>>()
.join(",");
Container names only resolve on the host that runs them. So any Postgres cluster with members on more than one node was broken — the connection string contained names that didn't resolve from the consumer's host.
The "fix" was either:
Temps v0.1.0 is option 2.
After v0.1.0, the connection string becomes one line:
postgresql://user:[email protected]:5432/db?target_session_attrs=read-write
DNS handles host enumeration. Failover flips the primary.* record within ~5 seconds. No consumer redeployment required.
For comparison's sake, here's what Kubernetes ships:
kube-system namespace. Pods get its ClusterIP as their first nameserver via /etc/resolv.conf (injected by kubelet).<service>.<namespace>.svc.cluster.local resolves to the service's ClusterIP — a stable virtual IP that kube-proxy load-balances across the service's pods.clusterIP: None) skip the VIP and return all pod IPs as multi-A records. This is how StatefulSets give you per-pod DNS like <pod-name>.<service>.<namespace>.svc.cluster.local._postgres._tcp.<service>.<namespace>.svc.cluster.local returns the port + target host.autopath plugin (CoreDNS) short-circuits the ndots=5 search-domain explosion for fully-qualified queries.Kubernetes DNS is essentially "every service has a name, every pod has a name, you compose them via search paths and namespace boundaries." It's been refined over 8 years. It's good.
Three tiers, scoped to clusters managed by Temps. Everything under .temps.local.
| Tier | What | Pattern | Example |
|---|---|---|---|
| 3 | Service identity | <svc>.temps.local | pg-orders.temps.local (multi-A) |
| 3 | Role alias (writes) | primary.<svc>.temps.local | primary.pg-orders.temps.local |
| 3 | Role alias (reads) | replica.<svc>.temps.local | replica.pg-orders.temps.local |
| 2 | Container identity | <svc>-<ord>.<svc>.temps.local | pg-orders-0.pg-orders.temps.local |
| 1 | L3 reachability | (IP) | 172.20.5.42 |
Citation Capsule: Temps v0.1.0's internal DNS plane runs a per-node Hickory resolver embedded in
temps-agent, listening on the bridge gateway address (172.x.x.1:53) and127.0.0.53:53. Each node serves the full zone from a localservice_endpointssnapshot synced from the control plane at a 1-second poll interval. The data is persisted to/var/lib/temps/dns/zone.json(fsynced on every generation bump) for failure isolation.node_dns_statetracks each node'sapplied_generationandhealthstatus ('healthy','degraded','stale','unknown').
This is the biggest architectural divergence. Kubernetes runs CoreDNS as a centralized Deployment (typically 2 replicas). Temps embeds a Hickory resolver in every temps-agent and runs it locally on each node.
| Property | Kubernetes (CoreDNS) | Temps v0.1.0 (Hickory per-node) |
|---|---|---|
| Topology | Centralized Deployment, 2+ replicas | One resolver per node |
| Failure domain | Cluster-wide (mitigated by replicas + NodeLocal DNSCache) | Per-node (containers on a healthy node aren't affected) |
| Lookup latency | Network hop to CoreDNS pod, typically 1–5ms | Localhost, sub-millisecond |
| Authority | CoreDNS pulls from Kubernetes API | Each node serves the full zone from a local snapshot |
| Failure isolation | Control plane down = cluster DNS works (stale state) | Control plane down = resolution works from on-disk snapshot |
Why per-node? Three reasons from ADR-011:
Honestly, Kubernetes has converged on something similar with NodeLocal DNSCache — a DaemonSet that runs a CoreDNS instance on each node specifically to cut latency and reduce conntrack pressure on the central pods. Temps's per-node-by-default is the same idea, made structural.
The "per-node serves the full zone" choice is what makes the Temps approach safer than naïve per-node DNS. Each node has the entire *.temps.local zone in memory. A node losing its agent doesn't lose name resolution — Docker containers can be configured with a peer node's bridge gateway as a secondary nameserver.
Kubernetes CoreDNS reads from the API server via an informer cache. When a Service is created, an EndpointSlice changes, or a pod IP changes, the watch event arrives at CoreDNS within milliseconds and DNS records update.
Temps uses a polling pattern, borrowed from how its existing internal API surfaces (peer lists, route tables) already work:
Agent: GET /internal/nodes/{id}/dns/changes?since=N (1s default poll)
← receives diff with generation > N, or full_snapshot if gap is large
writes records to local zone store
fsyncs to /var/lib/temps/dns/zone.json
ACKs by writing back applied_generation = M
The service_endpoints table has a monotonic generation column bumped on every mutation. Agents pull deltas. The control plane's node_dns_state table tracks each node's applied_generation and last_sync_at, so drift is visible — the ops query is:
SELECT node_id FROM node_dns_state
WHERE last_sync_at < now() - interval '60 seconds';
The control plane currently returns immediately (no server-side hold), so propagation is bounded by the 1-second poll interval — not a 30-second timeout. The maximum backoff on error is 30 seconds. For a system targeting ~6-second failover propagation, polling at 1s is more than sufficient.
In v0.1.0 testing, the median primary-promotion-to-DNS-flip time was 5–7 seconds (5s reconciler tick + up to 1s poll). libpq's reconnect logic handled the gap transparently with target_session_attrs=read-write.
This is where the comparison gets honest. Kubernetes DNS is feature-rich. Temps v0.1.0 deliberately is not.
In Kubernetes, every Service gets DNS automatically. Your payments service can call http://orders.default.svc.cluster.local without configuration.
In Temps v0.1.0, app deployments don't get auto-generated DNS records. The schema and resolver support it (the service_endpoints table has owner_kind = 'static' for free-form names), but lifecycle hooks for user deployments are deferred until there's a clear demand signal. You still wire app-to-app traffic through env vars or the proxy.
This is the single biggest gap vs Kubernetes. If your mental model is "every service has a name," Temps doesn't give you that for application containers in this release. Only managed databases get first-class DNS.
Kubernetes CoreDNS supports DNSSEC. Temps v0.1.0 does not.
The reasoning: internal-only zones, authenticated control plane, low value relative to the implementation cost. The threat model is "spoofing inside the cluster," and the mitigation is binding the resolver to <bridge_gateway> and 127.0.0.53 only — never 0.0.0.0. Containers can't reach the control-plane sync API directly; the agent calls it on their behalf with a node token.
If your compliance regime requires DNSSEC for internal queries, this is a gap.
The schema column is plain text and Hickory serves AAAA records when present, so the day IPv6 ships in temps-network, records "just appear" with no migration. But no IPv6-specific work landed in v0.1.0. The L3 overlay is v4-only.
Kubernetes auto-populates _<port>._<proto>.<service>.<namespace>.svc.cluster.local SRV records. The Temps schema supports record_type = 'SRV', but the reconciler only writes A records. SRV is plumbed but unused.
Kubernetes lets you customize per-pod dnsConfig with extra search paths, options like ndots:1, custom nameservers. Temps sets the bridge gateway via HostConfig.dns on every container. No per-container DNS customization.
CoreDNS supports wildcard plugins. Temps's resolver matches exact FQDNs. No wildcards.
Kubernetes ExternalName services CNAME to external names. Temps's resolver handles *.temps.local in-zone and forwards everything else to upstream resolvers (Cloudflare 1.1.1.1/1.0.0.1, Google 8.8.8.8) — but it doesn't synthesize CNAME records that bridge the two.
| Feature | Status |
|---|---|
| Per-container A records | Yes |
| Multi-A round-robin for service VIPs | Yes |
| Role-aliased records (primary/replica) | Yes (Postgres only) |
| ~5-second failover propagation | Yes |
| Per-node resolver with full zone | Yes |
| On-disk snapshot for control-plane outages | Yes |
| Aggressive low TTLs (5s primary, 30s replica) | Yes |
Upstream forwarder for non-temps.local queries | Yes (Cloudflare + Google) |
Drift detection via node_dns_state | Yes |
| Two-nameserver container config for HA | Yes (configurable) |
| Underlay fallback for non-overlay members | Yes |
| Janitor for orphan record cleanup | Yes (hourly) |
The single most useful operational property has been the "stale-but-serving" behavior. Restart the control plane while a Postgres app is running, and the resolvers serve the last-known zone from disk. Existing connections keep working; only new mutations are blocked. Compare to a freshly-bounced CoreDNS Deployment with no NodeLocal cache, where you can lose name resolution for the duration of the rolling restart.
Honest answer: most teams shouldn't pick Temps's DNS over Kubernetes's DNS. They should pick Temps over Kubernetes for the whole deployment story, and the DNS comes along for the ride.
Reasons to be on Temps with this DNS plane:
Reasons to stay on Kubernetes:
Temps is Apache 2.0, self-host free, or ~$6/mo on Temps Cloud (Hetzner cost + 30%, no per-seat fees). The internal DNS plane is included — no add-on, no extra config.
The ADR explicitly defers these. They're not commitments, but they're the natural next steps:
<app>.apps.temps.local). The schema supports it; missing piece is lifecycle hooks at deploy/scale time.With Temps, you don't configure anything. When you run temps serve (control plane) and join worker nodes with bunx @temps-sdk/cli join <url> <token>, each worker node's temps-agent automatically starts an embedded Hickory DNS resolver listening on the overlay bridge gateway. Every container you deploy gets the bridge IP injected as its nameserver via --dns, so *.temps.local names resolve immediately. No separate CoreDNS Deployment, no kube-dns, no external DNS server to manage.
CoreDNS is great. The reason for embedding Hickory in temps-agent is integration: lifecycle hooks, in-process error handling, no separate binary to ship and version, Rust-native error propagation into the existing audit trail. CoreDNS would have meant another process per node with its own configuration, restart policy, and failure mode. Hickory is a Tokio task in a process that already runs.
pg-orders.temps.local from a sidecar?Yes — the bridge gateway resolver answers any DNS query for *.temps.local from any container attached to the temps-overlay bridge. Sidecars, init containers, anything. Non-temps.local queries forward to Cloudflare and Google.
The resolver keeps serving the last-known zone from /var/lib/temps/dns/zone.json (fsynced on every generation bump). node_dns_state.health flips to 'stale' so ops can tell. Existing apps keep resolving; mutations are paused until the control plane comes back.
Not directly. Temps's DNS is for containers attached to the temps-overlay Docker bridge. If you're running Temps and Kubernetes side-by-side, each has its own DNS plane and the two don't federate. Apps on Kubernetes that need to reach a Temps-managed Postgres cluster would use the cluster's external endpoint, not the internal *.temps.local name.
Hickory in-process is approximately 3MB RSS plus a small zone cache (few KB for typical clusters). The agent already runs on every node; the resolver is a Tokio task inside it. No separate process, no separate logs, no separate health check.
Yes. Temps is Apache 2.0 — free to self-host. Temps Cloud is ~$6/mo (Hetzner server cost + 30% margin), with no per-seat pricing, no bandwidth bills, and no vendor lock-in.
Related: Read the full v0.1.0 release notes