Deploy a secondary DNS server and resolver

Introduction

In the first tutorial, we deployed a bind charm and served our first DNS record for flying-saucer.local. That setup works well for testing, but in production you would not expose the primary DNS server directly to clients. Instead, you would place a secondary DNS server in front of it and let a resolver handle the actual client queries.

In this tutorial we will extend our deployment with two new charms:

  • dns-secondary – a secondary DNS server that receives zone data from bind via zone transfers.

  • dns-resolver – a caching resolver that knows how to reach the secondary for our zone.

By the end, you will have a proper hidden primary architecture: bind holds the zone data, dns-secondary serves it to the outside world, and dns-resolver answers client queries. It will take us about 15 minutes.

What you’ll need

  • A working Juju environment with a LXD cloud, as set up in the first tutorial.

What you’ll do

  1. Deploy bind and dns-integrator (recap from the first tutorial).

  2. Deploy dns-secondary and integrate it with bind.

  3. Deploy dns-resolver and integrate it with dns-secondary.

  4. Verify the full resolution chain.

Important

Should you get stuck or notice issues, please get in touch on Matrix or Discourse.

Set things up

Follow the steps in Your first DNS charm deployment up to and including the Deploy dns-integrator section. Once bind and dns-integrator are deployed, integrated, and active, come back here to continue.
You don’t need to scale up bind for the rest of this tutorial. If you’ve scaled up bind to three units and want to have exactly the same status as we’re going to show here, remove those units with the juju remove-unit command.

At this point, juju status should look similar to:

ubuntu@dns-dev:~$
juju status
App             Version  Status  Scale  Charm           Channel      Rev  Exposed  Message
bind                     active      1  bind            latest/edge   81  no       active
dns-integrator           active      1  dns-integrator  latest/edge    2  no

Unit               Workload  Agent  Machine  Public address  Ports          Message
bind/0*            active    idle   0        10.79.131.37    53/tcp 53/udp  active
dns-integrator/0*  active    idle   1        10.79.131.165

Deploy dns-secondary

The dns-secondary charm runs a secondary DNS server. It receives zone data from bind through zone transfers, so it always has an up-to-date copy of your records. Let’s deploy it:

juju deploy dns-secondary --channel=latest/edge

After waiting a bit for the machine to come up, juju status should show:

ubuntu@dns-dev:~$
juju status
App             Version  Status   Scale  Charm           Channel      Rev  Exposed  Message
bind                     active       1  bind            latest/edge   81  no       active
dns-integrator           active       1  dns-integrator  latest/edge    2  no
dns-secondary            blocked      1  dns-secondary   latest/edge    3  no       Needs to be related with a primary charm

Unit               Workload  Agent  Machine  Public address  Ports          Message
bind/0*            active    idle   0        10.79.131.37    53/tcp 53/udp  active
dns-integrator/0*  active    idle   1        10.79.131.165
dns-secondary/0*   blocked   idle   4        10.79.131.21    53/tcp 53/udp  Needs to be related with a primary charm

The dns-secondary charm is in a blocked state because it doesn’t know where to get its zone data from yet. Let’s integrate it with bind:

juju integrate dns-secondary bind

After a moment, everything should be active:

ubuntu@dns-dev:~$
juju status
App             Version  Status  Scale  Charm           Channel      Rev  Exposed  Message
bind                     active      1  bind            latest/edge   81  no       active
dns-integrator           active      1  dns-integrator  latest/edge    2  no
dns-secondary            active      1  dns-secondary   latest/edge    3  no       1 zones, 1 primary addresses

Unit               Workload  Agent  Machine  Public address  Ports          Message
bind/0*            active    idle   0        10.79.131.37    53/tcp 53/udp  active
dns-integrator/0*  active    idle   1        10.79.131.165
dns-secondary/0*   active    idle   4        10.79.131.21    53/tcp 53/udp  1 zones, 1 primary addresses

The message “1 zones, 1 primary addresses” tells us that dns-secondary has received the flying-saucer.local zone from bind. Let’s verify by querying the secondary directly for our TXT record:

ubuntu@dns-dev:~$
dig @<IP> message.flying-saucer.local TXT +short
"Hello"

Note

Replace <IP> with the public address of your dns-secondary unit, as shown in juju status. In my case, it would be 10.79.131.21.

The secondary is serving the same record as the primary. Now let’s look at the NS records for the zone:

ubuntu@dns-dev:~$
dig @<IP> flying-saucer.local NS +short
ns.flying-saucer.local.

Notice that the name server for the zone points to the secondary, not to bind. This is the hidden primary architecture in action: bind is the source of truth for the zone data, but it is not advertised as the name server. Only the secondary is publicly visible. This means you can manage and update your zone on bind without exposing it directly to client traffic.

Deploy dns-resolver

With the secondary in place, we can now add a caching resolver. The dns-resolver charm runs a recursive resolver that learns about your zones through the authority chain. Let’s deploy it:

juju deploy dns-resolver --channel=latest/edge

After the machine is ready, juju status should show:

ubuntu@dns-dev:~$
juju status
App             Version  Status   Scale  Charm           Channel      Rev  Exposed  Message
bind                     active       1  bind            latest/edge   81  no       active
dns-integrator           active       1  dns-integrator  latest/edge    2  no
dns-resolver             blocked      1  dns-resolver    latest/edge    1  no       Needs to be related with an authority charm
dns-secondary            active       1  dns-secondary   latest/edge    3  no       1 zones, 1 primary addresses

Unit               Workload  Agent  Machine  Public address  Ports          Message
bind/0*            active    idle   0        10.79.131.37    53/tcp 53/udp  active
dns-integrator/0*  active    idle   1        10.79.131.165
dns-resolver/0*    blocked   idle   5        10.79.131.150   53/tcp 53/udp  Needs to be related with an authority charm
dns-secondary/0*   active    idle   4        10.79.131.21    53/tcp 53/udp  1 zones, 1 primary addresses

The resolver is blocked because it needs to know which server is authoritative for our zone. We integrate it with dns-secondary:

juju integrate dns-secondary dns-resolver

After things settle, all charms should be active. Let’s check with juju status --relations to see the full picture:

ubuntu@dns-dev:~$
juju status --relations
App             Version  Status  Scale  Charm           Channel      Rev  Exposed  Message
bind                     active      1  bind            latest/edge   81  no       active
dns-integrator           active      1  dns-integrator  latest/edge    2  no
dns-resolver             active      1  dns-resolver    latest/edge    1  no       1 zone, 1 authority address
dns-secondary            active      1  dns-secondary   latest/edge    3  no       1 zones, 1 primary addresses

Unit               Workload  Agent  Machine  Public address  Ports          Message
bind/0*            active    idle   0        10.79.131.37    53/tcp 53/udp  active
dns-integrator/0*  active    idle   1        10.79.131.165
dns-resolver/0*    active    idle   5        10.79.131.150   53/tcp 53/udp  1 zone, 1 authority address
dns-secondary/0*   active    idle   4        10.79.131.21    53/tcp 53/udp  1 zones, 1 primary addresses

Integration provider               Requirer                           Interface               Type     Message
bind:bind-peers                    bind:bind-peers                    bind-instance           peer
bind:dns-record                    dns-integrator:dns-record          dns_record              regular
bind:dns-transfer                  dns-secondary:dns-transfer         dns_transfer            regular
dns-resolver:dns-resolver-peers    dns-resolver:dns-resolver-peers    dns-resolver-instance   peer
dns-secondary:dns-authority        dns-resolver:dns-authority         dns_authority           regular
dns-secondary:dns-secondary-peers  dns-secondary:dns-secondary-peers  dns-secondary-instance  peer

The relations table shows the full chain: dns-integrator sends records to bind via dns_record, bind transfers zones to dns-secondary via dns_transfer, and dns-secondary provides authority information to dns-resolver via dns_authority.

Now let’s verify that the resolver can answer queries for our zone:

ubuntu@dns-dev:~$
dig @<IP> message.flying-saucer.local TXT +short
"Hello"

Note

Replace <IP> with the public address of your dns-resolver unit, as shown in juju status. In my case, it would be 10.79.131.150.

The resolver successfully resolved our TXT record. It did so by forwarding the query to dns-secondary, which holds the zone data it received from bind.

Conclusion

You’ve reached the end of this tutorial. Building on the first tutorial, you have now:

  • deployed a secondary DNS server that receives zone data from bind

  • deployed a caching resolver that answers client queries

  • verified a full hidden primary architecture where bind is never directly exposed to clients

Your DNS deployment now follows a production-ready pattern: records are managed through dns-integrator, stored in bind, transferred to dns-secondary, and served to clients through dns-resolver.

Tear things down

If you’d like to quickly tear things down, start by exiting the Multipass VM:

exit

And then you can proceed with its deletion:

multipass delete dns-dev
multipass purge