Internal DNS for Docker Clusters Without K8s
How Temps resolves *.temps.local for HA databases and multi-node apps with a per-node Hickory resolver: an opt-in CoreDNS alternative without Kubernetes.
Temps v0.1.0-beta.56 ships opt-in internal DNS (Cluster DNS) for self-hosted Docker clusters without Kubernetes — a per-node Hickory resolver embedded in temps-agent that gives managed databases and, on worker nodes, deployed apps stable *.temps.local hostnames, and keeps resolving from the last zone it applied if the control plane is unreachable. It is experimental and off by default.
If you run a self-hosted Docker cluster and need service names that survive container moves and failovers — without standing up Kubernetes — Temps includes this behind one toggle. No extra process to run. No CoreDNS Deployment to manage. The reference is Networking → Internal DNS resolver; for the database side, see HA databases.
How Do You Get Internal DNS for a Self-Hosted Docker Cluster Without Kubernetes?
The short answer: run Temps, join your worker nodes with temps join <control-plane-url> <join-token> --private-address <worker-ip> over a private network you provide (generate the token under Settings → Worker Nodes; see multi-node), and turn on Cluster DNS on the Worker Nodes page (Enable cluster DNS). It's a cluster-wide, experimental setting. Once it's on, each worker's temps-agent runs an embedded Hickory DNS resolver (the control plane runs one too, fed straight from its database), and containers get the bridge gateway IP as their first nameserver via Docker's --dns flag, followed by the host's own nameservers. Names like pg-orders.temps.local then resolve on every node from a resolver on the same host, with no separate DNS server to operate.
How it works, in three points:
-
Per-node full-zone authority, 1-second poll cadence: Every
temps-agentembeds a Hickory DNS resolver and serves the complete*.temps.localzone 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-providersqueries pg_auto_failover, upsertsprimary.<svc>.temps.local(TTL 5s) andreplica.<svc>.temps.local(TTL 30s), and rotates the A record on promotion. libpq withtarget_session_attrs=read-writereconnects transparently. (Source: ADR-011) -
Control-plane-outage resilience: The resolver keeps the zone in memory and writes
zone.jsonto/var/lib/temps/dns/(temp file plus atomic rename, best-effort) on every applied generation, so it can reload the last zone after an agent restart. If the control plane is unreachable, containers keep resolving the last-known records. The node'slast_sync_atinnode_dns_statestops advancing, so drift is visible with a query; existing app connections are unaffected.
Comparison: Temps internal DNS vs Kubernetes CoreDNS vs Manual IP Management
| Temps (v0.1.0-beta.56) | 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 path | Resolver on the same host (bridge gateway), no network hop | Network hop to a CoreDNS pod | N/A |
| Default sync cadence | 1-second poll | Watch events (milliseconds) | Manual redeploy |
| Failover propagation | ~6 seconds by design | Milliseconds (watch events) | Manual redeploy required |
| Control plane outage | Keeps serving the last-known zone | Depends on NodeLocal DNSCache | No impact |
| App-to-app DNS | Same-project apps on worker nodes (<environment>.<project>.temps.local) | Yes (all services) | No |
| Setup complexity | Opt-in toggle (experimental), resolver included in temps-agent | Cluster DNS infra required | Manual maintenance |
| License | MIT / Apache 2.0 | Apache 2.0 | N/A |
Outside Kubernetes, the usual alternatives are Docker Swarm's built-in service DNS (every Swarm service gets a name and a VIP on its overlay network, with no extra setup — but you're on Swarm's scheduler) and Consul DNS (service registration plus health-checked <service>.service.consul names, at the cost of running a Consul cluster). Temps sits closer to Swarm in effort, scoped to the databases and apps it manages.
TL;DR: Temps v0.1.0-beta.56 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). It is experimental and off by default. The architecture borrows ideas from Kubernetes CoreDNS but is scoped narrowly: managed-database HA, plus internal names that same-project apps on worker nodes can call. 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-beta.56 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.
What Problem This Actually Solves
In early v0.1.0 betas, 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:
// Illustrative: early betas — 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:
- Hand consumers a list of pre-resolved IPs via env var. Now adding or removing a replica forces redeploying every consumer. That's not HA — that's a kill switch with extra steps.
- Build an internal DNS plane.
The v0.1.0 betas took option 2.
With Cluster DNS enabled, the connection string can become one line:
postgresql://user:pw@pg-orders.temps.local:5432/db?target_session_attrs=read-write
DNS handles host enumeration. Failover flips the primary.* record within roughly 6 seconds. No consumer redeployment required. Linked apps get a multi-host POSTGRES_URL with target_session_attrs=read-write; when every member runs on the control plane its hosts are plain container names, but once members span worker nodes they are per-member *.temps.local names — so a multi-node cluster needs Cluster DNS turned on.
How Kubernetes Does It
For comparison's sake, here's what Kubernetes ships:
- CoreDNS runs as a Deployment in the
kube-systemnamespace. Pods get its ClusterIP as their first nameserver via/etc/resolv.conf(injected by kubelet). <service>.<namespace>.svc.cluster.localresolves to the service's ClusterIP — a stable virtual IP that kube-proxy load-balances across the service's pods.- Headless services (
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. - SRV records are populated for named ports.
_postgres._tcp.<service>.<namespace>.svc.cluster.localreturns the port + target host. - ExternalName services proxy DNS to an external name via CNAME.
autopathplugin (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.
How Temps Does It
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 |
Under the hood, the per-node Hickory resolver embedded in temps-agent listens on the bridge gateway address (172.x.x.1:53) and, on worker nodes, 127.0.0.53:53. Each node serves the full zone from a local service_endpoints snapshot synced from the control plane at a 1-second poll interval. The data is persisted to /var/lib/temps/dns/zone.json (atomic rename on every generation bump, best-effort) for failure isolation. node_dns_state tracks each node's applied_generation and last_sync_at.
Per-Node Resolver vs Central CoreDNS
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-beta.56 (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 path | Network hop to a CoreDNS pod | Same host (bridge gateway), no network hop |
| 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 keeps working from the last-known zone |
Why per-node? Three reasons from ADR-011:
- Blast radius. A central resolver is a cluster-wide SPOF. Per-node resolvers fail independently.
- Latency. A resolver on the same host answers without a network hop; central DNS over the underlay inherits the network's tail latency on every lookup.
- Authority is local. Each node already knows what containers it hosts.
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, so no node depends on another node's resolver. If a node's agent stops, containers on that node lose *.temps.local resolution, but outbound DNS keeps working: the host's own nameservers are listed after the internal resolver as fallbacks.
Sync Model: Polling vs Kubernetes Informers
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 /api/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
writes /var/lib/temps/dns/zone.json (tmp file + atomic rename)
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.
By design, primary promotion to DNS flip is bounded at roughly 6 seconds (5s reconciler tick + up to 1s poll), plus up to the 5s primary.* TTL that clients may have cached. Clients using target_session_attrs=read-write reconnect to the new primary once their old connection drops.
What Temps Does NOT Support in v0.1.0-beta.56
This is where the comparison gets honest. Kubernetes DNS is feature-rich. Temps v0.1.0-beta.56 deliberately is not.
App-to-App Service Discovery (Partial)
In Kubernetes, every Service gets DNS automatically. Your payments service can call http://orders.default.svc.cluster.local without configuration.
In Temps, apps get internal names only on multi-node clusters with Cluster DNS enabled: <environment>.<project>.temps.local (for example http://production.api.temps.local), served over plain HTTP on port 80 by an internal proxy on each worker node. That proxy only answers callers from the same project — a container from another project gets a 403. Those records are published for worker nodes only, so the name doesn't exist on a single-server install — there, call other apps through their domain (networking docs).
So the gap vs Kubernetes is narrower than it was, but real: there's no per-service name on a single node, no cross-project internal calls, no namespaces, and the feature is still experimental.
DNSSEC
Kubernetes CoreDNS supports DNSSEC. Temps v0.1.0-beta.56 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, on worker nodes, 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.
IPv6 (AAAA Records)
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 the v0.1.0 betas. The L3 overlay is v4-only.
SRV Records for Named Ports
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.
Per-Container DNS Customization
Kubernetes lets you customize per-pod dnsConfig with extra search paths, options like ndots:1, custom nameservers. With Cluster DNS on, Temps sets the bridge gateway via HostConfig.dns on every container, followed by the host's own nameservers. No per-container DNS customization.
Wildcard Records
CoreDNS supports wildcard plugins. Temps's resolver matches exact FQDNs. No wildcards.
CNAME Chains Across the Boundary
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.
What Is Actually Shipped in v0.1.0-beta.56
| Feature | Status |
|---|---|
| Per-container A records | Yes |
| Multi-A round-robin for service VIPs | Yes |
| Role-aliased records (primary/replica) | Yes (Postgres only) |
App names on worker nodes (<environment>.<project>.temps.local) | Yes (multi-node, same-project callers only) |
| ~6-second failover propagation | Yes |
| Per-node resolver with full zone | Yes |
| On-disk zone snapshot, reloaded after an agent restart | Yes (best-effort) |
| Aggressive low TTLs (5s primary, 30s replica) | Yes |
Upstream forwarder for non-temps.local queries | Yes (Cloudflare + Google) |
Drift visible via node_dns_state (manual query) | Yes |
| Host-DNS fallback after the internal resolver (up to 3 nameservers) | Yes (not configurable) |
| Underlay fallback for non-overlay members | Yes |
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 keep serving the last-known zone from memory (the on-disk snapshot matters when an agent itself restarts). 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.
When to Choose Temps DNS Over Kubernetes
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:
- You run a small fleet (1–50 services) and don't want to operate Kubernetes.
- You need managed database HA with automatic failover and don't want to glue together AWS RDS or Crunchy.
- You're using Docker, not Kubernetes — the rest of the cluster ecosystem (storage, networking, monitoring) is sized for that.
- You're OK with ~6-second failover latency for databases and don't need millisecond DNS propagation.
Reasons to stay on Kubernetes:
- You already have CoreDNS at scale. Don't migrate.
- You need DNS-based service discovery for every service, including on single-node installs and across namespaces.
- You need DNSSEC, headless service patterns, or pod-level DNS customization. Not in v0.1.0-beta.56.
- You're at 100+ services. The Kubernetes ecosystem is tuned for that scale.
Temps is licensed MIT / Apache 2.0 and free to self-host. The internal DNS plane is included — no add-on, one cluster-wide toggle.
What's Coming in Future Releases
The ADR explicitly defers these. They're not commitments, but they're the natural next steps:
- App names on single-server installs. Today app records are published for worker nodes only.
- Redis Sentinel and MongoDB ReplicaSet reconcilers. Same machinery, different upstream APIs to query.
- SRV record reconciliation for services that publish multiple ports.
- Push-based sync instead of polling. Re-evaluate if sub-second propagation is needed.
- DNSSEC for compliance-regulated deployments. Cost vs value question, depends on demand.
FAQ
How do you set up internal DNS for a Docker cluster without Kubernetes?
With Temps, join worker nodes with the server binary — temps join <control-plane-url> <join-token> --private-address <worker-ip> over a private network you provide (generate the token under Settings → Worker Nodes) — then enable Cluster DNS on the Worker Nodes page (it is experimental and off by default). Each worker node's temps-agent then starts an embedded Hickory DNS resolver listening on the bridge gateway. Every container you deploy gets the bridge IP injected as its first nameserver via --dns, so *.temps.local names resolve immediately. No separate CoreDNS Deployment, no kube-dns, no external DNS server to manage.
Why not just use CoreDNS?
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.
Can my apps query 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 temps0 bridge. Sidecars, init containers, anything. Non-temps.local queries forward to Cloudflare and Google.
What happens if the control plane is unreachable?
The resolver keeps serving the last-known zone it holds in memory; if the agent restarts meanwhile, it reloads that zone from /var/lib/temps/dns/zone.json. The node's last_sync_at in node_dns_state stops advancing, so a query shows the drift. Existing apps keep resolving; mutations are paused until the control plane comes back.
Does this work with Kubernetes in a hybrid setup?
Not directly. Temps's DNS is for containers attached to the temps0 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.
What is the resource cost per node?
Small: the agent already runs on every node, and the resolver is a Tokio task inside it holding the zone in memory. No separate process, no separate logs, no separate health check.
Is Temps free to self-host?
Yes. Temps is licensed MIT / Apache 2.0 — free to self-host. Temps Cloud, a hosted control plane for your own servers, is coming soon from $29/month; there's no per-seat pricing, no bandwidth bills, and no vendor lock-in.
Related: Self-hosted Postgres high availability · Multi-node Docker cluster without Kubernetes · Read the full v0.1.0 release notes
Get weekly updates