Skip to content

dns

The dns role runs CoreDNS as a compose project in dns_dir (/opt/dns). It answers the names in dns_records for the clients in dns_allow, forwards every other name to dns_upstreams, and refuses all other clients. Use it for private names, for example to resolve internal host names to private addresses for a tailnet.

  • Writes a Corefile, a records file and a compose file, and starts the project with Docker Compose. CoreDNS runs with host networking, so it sees the real client addresses that dns_allow is checked against.
  • Binds UDP and TCP port 53 only on the addresses in dns_bind, plus, with dns_bind_tailscale, the node’s Tailscale IPv4 and IPv6 addresses.
  • Answers dns_records with the TTL dns_ttl, from a managed hosts file in dns_dir. The host’s own /etc/hosts is not served. Changing a record needs no restart: CoreDNS re-reads the file within a few seconds, and the cache may keep serving the old answer: up to dns_ttl seconds for a changed record, and up to dns_cache seconds (capped by the upstream TTL) for a new record whose name was forwarded before. Names that are not listed are forwarded to dns_upstreams and cached for dns_cache seconds (0 turns the cache off).
  • Runs the container read-only, with all capabilities dropped except NET_BIND_SERVICE, with no-new-privileges, with CPU and memory limits (dns_cpus, dns_memory) and restart: unless-stopped. A change of the Corefile or the compose file recreates the container.

The role never runs as an open resolver. It stops when dns_allow is empty, and it refuses wildcard bind addresses (0.0.0.0, ::) and wildcard client networks (0.0.0.0/0, ::/0).

VariableDefaultMeaning
dns_bind[]Addresses CoreDNS binds. Never a wildcard. Give at least one, or set dns_bind_tailscale.
dns_bind_tailscalefalseAlso bind the addresses printed by tailscale ip -4 and tailscale ip -6.
dns_allow[]Client networks (CIDR) that get answers. Required.
dns_upstreams[1.1.1.1, 8.8.8.8]Resolvers that unknown names are forwarded to.
dns_records[]Names answered locally: name (lower case) and addresses (IPv4 and IPv6).
dns_ttl60TTL of the local records in seconds.
dns_cache300Seconds forwarded answers are cached. 0 disables the cache.
dns_log_queriesfalseLog every query.
dns_imagepinned CoreDNS tagCoreDNS image.

All variables: dns in the reference.

A resolver for private names on a private address and on loopback, for clients on a private network and for the host itself:

- hosts: resolver
become: true
roles:
- noixdev.infra.docker
- role: noixdev.infra.dns
vars:
dns_bind: [127.0.0.1, 192.0.2.53]
dns_allow: [127.0.0.0/8, 192.0.2.0/24]
dns_records:
- { name: app.internal.example.com, addresses: [192.0.2.10] }
- { name: dual.internal.example.com, addresses: [192.0.2.11, "2001:db8::11"] }

app.internal.example.com answers with its IPv4 address, dual.internal.example.com with both. Any other name, such as example.org, is forwarded to the upstream resolvers.

  • A known name asked for a record type it has no address for gets an empty answer (NOERROR, no records). Asking app.internal.example.com for AAAA, when only an IPv4 address is listed, does not reach the upstream resolvers. The public record, if there is one, is never returned.
  • A client outside dns_allow gets REFUSED.
  • An unknown name is forwarded to dns_upstreams.
  1. Bind the node’s Tailscale addresses with dns_bind_tailscale: true and allow the tailnet ranges:

    - hosts: resolver
    become: true
    roles:
    - role: noixdev.infra.tailscale
    vars:
    tailscale_auth_key: "{{ vault_tailscale_auth_key }}"
    tailscale_accept_dns: false
    - noixdev.infra.docker
    - role: noixdev.infra.dns
    vars:
    dns_bind_tailscale: true
    dns_allow: [100.64.0.0/10, "fd7a:115c:a1e0::/48"]
    dns_records:
    - { name: app.internal.example.com, addresses: [100.64.0.10] }
  2. In the Tailscale admin console open DNS and add the node’s Tailscale addresses as nameservers, in one of two ways:

    • Split DNS for the internal domain, with Override local DNS off. If the node or its tailscaled is down, only the internal names fail.
    • Global nameservers with Override local DNS on. Every client then sends all its DNS through this node, so if the node or tailscaled is down, all name resolution on those clients fails.

    With Split DNS only names in the internal domain reach this server; clients resolve all other names as before. With the global nameserver every name reaches it, and names without a record are forwarded to dns_upstreams. If the tailnet policy (ACLs or grants) is not allow-all, it must allow UDP and TCP port 53 to this node for all members. If the node’s Tailscale addresses change (for example after it is registered again), run the play again and update the nameserver entries.

  3. Keep tailscale_accept_dns: false on this node, so it never resolves through itself.

  • A bind address must exist when CoreDNS starts. Tailscale IPv6 addresses come up after boot. Until the address exists CoreDNS cannot bind (it exits completely if one address is missing), so the container restarts (restart: unless-stopped) until it can. This is expected, not an error. With base_nonlocal_bind: true in the base role, CoreDNS can bind addresses that are not up yet, so the restart loop does not happen.
  • dns_bind_tailscale needs Tailscale up. The role runs tailscale ip and fails when that fails. Apply tailscale first and make sure the node is logged in.
  • There is no wildcard bind, and dns_allow defaults to an empty list, which stops the role. Both are deliberate, so the server cannot be used as an open resolver by accident.
  • Every record needs a lower-case name and at least one address. The role checks this before it changes anything.
  • The role needs Docker. Apply docker first.