Local DNS without forwarding
Published on Friday, 21 November, 2025Table of content
Intro
Apparently I have some issues with configuring DNS servers. So here is yet another DNS guide.
Generally when you find a guide to install DNS server, you point your server to some upstream DNS server owned by your ISP, or Cloudflare, or Google. But you don't have to. Root DNS servers are public, so is basically any name server in existance (well any nameserver for publicaly available websites (and quite a few private ones as well, but let's not allow that to distract us)). Unless you are on an awful connection, only downside is more requests. It can be slower (if you count microseconds) than using something like Cloudflare because they have a lot of DNS records cached, but if you go to something exotic, they have to find it as well, so if anything, might be even slower. But on the bright side you are not using Cloudflare.
And this doesn't really improve your privacy (neither does using Google or Cloudflare - if anything it's worse), standard DNS is just plain old UDP, your ISP sees all your trafic, so they can know all your DNS requests.
You could use DNS over HTTPS but then you have to use something upstream, so now you are trading your ISP not knowing your DNS requests to someone like Cloudflare knowing it. And your ISP still sees all the IP addresses you request, so you are yout making it worse (at least that's what i think).
Just a note, this is not some rambling against Google or Cloudflare, I use both, I don't trust either, do your own research. Just trying to unfog how everything works.
Now, this is mostly config change, nothing revolutionary about setting it up, I'll just write the whole thing so you don't have to read two articles.
In meantime I've switched all my servers that were on CentOS to debian. I was beyond annoyed when i saw that i can't upgrade my CentOS and had to reinstall, so debian it is.
Container image
Same as always i run my stuff in podman you can use docker or anything else, as long as it runs containers.
On debian these days you can just run:
# apt update
# apt install podman
For DNS server we'll use bind. Since i never found an official bind container, let's roll our own (or just use mine).
Create Containerfile:
FROM alpine:latest
LABEL maintainer="Marvin Sinister"
RUN addgroup -S -g 5353 bind && adduser -S -u 5353 -G bind bind; \
apk add --no-cache ca-certificates bind; \
mkdir /var/cache/bind;
RUN chown -R bind: /etc/bind; \
chown -R bind: /var/cache/bind;
VOLUME /etc/bind
USER bind
CMD ["/bin/sh", "-c", "/usr/sbin/named -g -4 -p 5053"]
It's simple, latest alpine as base image, install bind, fix some permissions, run bind.
You can build the container now:
$ podman build -t bind .
STEP 1/6: FROM alpine:latest
STEP 2/6: LABEL maintainer="Marvin Sinister"
--> ab38a21b20a9
STEP 3/6: RUN addgroup -S -g 5353 bind && adduser -S -u 5353 -G bind bind; apk add --no-cache ca-certificates bind; mkdir /var/cache/bind;
fetch https://dl-cdn.alpinelinux.org/alpine/v3.22/main/x86_64/APKINDEX.tar.gz
fetch https://dl-cdn.alpinelinux.org/alpine/v3.22/community/x86_64/APKINDEX.tar.gz
(1/33) Installing brotli-libs (1.1.0-r2)
...
STEP 6/6: CMD ["/bin/sh", "-c", "/usr/sbin/named -g -4 -p 5053"]
COMMIT bind
--> 088401d8ff46
Successfully tagged localhost/bind:latest
088401d8ff463bb8aceeede9c620cd29b49738b262d71e57595cbd76541a04d1
The image is now ready:
$ podman images
REPOSITORY TAG IMAGE ID CREATED SIZE
localhost/bind latest 088401d8ff46 About a minute ago 24.6 MB
docker.io/library/alpine latest 706db57fb206 6 weeks ago 8.62 MB
Config
I keep all my configs in /containers folder, so we'll use /containers/bind/ for this.
First we need to create base config, /containers/bind/named.conf:
// This is the primary configuration file for the BIND DNS server named.
//
// If you are just adding zones, please do that in /etc/bind/named.conf.d/named.conf.local
include "/etc/bind/named.conf.d/*";
Nothing special, we'll just use include function to add all files to named.conf.d folder.
Next we can create the controls config, `/containers/bind/named.conf.d/named.conf.controls:
controls {
inet 127.0.0.1 port 5953
allow { 127.0.0.1; } keys { "rndc-key"; };
};
This config references rndc-key which is shared secret for controling bind. To generate the key we can use the container we just created, run:
$ podman run --rm bind:latest rndc-confgen
It will produce something similar to:
# Start of rndc.conf
key "rndc-key" {
algorithm hmac-sha256;
secret "MZPUL69DtqFeVn90yo988yVPJHWwUPsD46IvIHMDdSs=";
};
options {
default-key "rndc-key";
default-server 127.0.0.1;
default-port 953;
};
# End of rndc.conf
# Use with the following in named.conf, adjusting the allow list as needed:
# key "rndc-key" {
# algorithm hmac-sha256;
# secret "MZPUL69DtqFeVn90yo988yVPJHWwUPsD46IvIHMDdSs=";
# };
#
# controls {
# inet 127.0.0.1 port 953
# allow { 127.0.0.1; } keys { "rndc-key"; };
# };
# End of named.conf
We want to take the first part of that file:
key "rndc-key" {
algorithm hmac-sha256;
secret "MZPUL69DtqFeVn90yo988yVPJHWwUPsD46IvIHMDdSs=";
};
And save it as /containers/bind/named.conf.d/rndc.key.
WARNING! This key is unique, you should generate it and not share it with anyone! DO NOT USE THE ONE IN THIS DOCUMENT (or do, I won't stop you).
Last thing is the options config, /containers/bind/named.conf.d/named.conf.options:
options {
directory "/var/cache/bind";
forwarders {
};
recursion yes;
allow-query { any; };
dnssec-validation auto;
auth-nxdomain no; # conform to RFC1035
listen-on port 5353 { any; };
};
This is very basic config, we define cache, we set forwarders to none (which is all the magic), enable recursion, allow query from anywhere, enable dnsssec, and set port to 5353. You can set the port to anything, but we'll be publishing them anyway, and this way you don't have to worry about privileged ports.
At this point your folder structure should look something like this:
/containers/bind/
├── named.conf
└── named.conf.d
├── named.conf.controls
├── named.conf.options
└── rndc.key
Testing
Nothing left to do but run it:
$ podman run --rm --name bind -v /containers/bind:/etc/bind:Z -p 5353:5353/udp bind:latest
21-Nov-2025 21:56:26.636 starting BIND 9.20.16 (Stable Release) <id:c97aa2d>
21-Nov-2025 21:56:26.636 running on Linux x86_64 6.7.12-amd64 #1 SMP PREEMPT_DYNAMIC Debian 6.7.12-1 (2024-04-24)
21-Nov-2025 21:56:26.636 built with '--build=x86_64-alpine-linux-musl' '--host=x86_64-alpine-linux-musl' '--prefix=/usr' '--sysconfdir=/etc/bind' '--localstatedir=/var' '--mandir=/usr/share/man' '--infodir=/usr/share/info' '--with-gssapi=yes' '--with-libxml2' '--with-json-c' '--with-openssl=yes' '--enable-dnstap' '--enable-largefile' '--enable-linux-caps' '--enable-shared' '--disable-static' 'build_alias=x86_64-alpine-linux-musl' 'host_alias=x86_64-alpine-linux-musl' 'CC=cc' 'CFLAGS=-Os -fstack-clash-protection -Wformat -Werror=format-security -fno-plt -g -D_GNU_SOURCE' 'LDFLAGS=-Wl,--as-needed,-O1,--sort-common -Wl,-z,pack-relative-relocs'
21-Nov-2025 21:56:26.636 running as: named -g -4 -p 5053
21-Nov-2025 21:56:26.636 compiled by GCC 14.2.0
21-Nov-2025 21:56:26.636 compiled with OpenSSL version: OpenSSL 3.5.4 30 Sep 2025
21-Nov-2025 21:56:26.636 linked to OpenSSL version: OpenSSL 3.5.4 30 Sep 2025
21-Nov-2025 21:56:26.636 compiled with libuv version: 1.51.0
21-Nov-2025 21:56:26.636 linked to libuv version: 1.51.0
21-Nov-2025 21:56:26.636 compiled with liburcu version: 0.15.2
...
21-Nov-2025 21:56:26.648 command channel listening on 127.0.0.1#5953
21-Nov-2025 21:56:26.648 managed-keys-zone: loaded serial 0
21-Nov-2025 21:56:26.692 all zones loaded
21-Nov-2025 21:56:26.692 FIPS mode is disabled
21-Nov-2025 21:56:26.692 running
21-Nov-2025 21:56:26.720 managed-keys-zone: Initializing automatic trust anchor management for zone '.'; DNSKEY ID 20326 is now trusted, waiving the normal 30-day waiting period.
21-Nov-2025 21:56:26.720 managed-keys-zone: Initializing automatic trust anchor management for zone '.'; DNSKEY ID 38696 is now trusted, waiving the normal 30-day waiting period.
You can use any port you want, I'm using 5353 here because I'm running it rootless, but if you plan to use DNS server for anything use port 53. To test it we can just run dig:
$ dig @127.0.0.1 -p 5353 google.com
; <<>> DiG 9.19.21-1+b1-Debian <<>> @127.0.0.1 -p 5353 google.com
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 25478
;; flags: qr rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 522afe8dcca79cea010000006920e0cf9dab7a58837daafa (good)
;; QUESTION SECTION:
;google.com. IN A
;; ANSWER SECTION:
google.com. 300 IN A 142.250.186.78
;; Query time: 368 msec
;; SERVER: 127.0.0.1#5353(127.0.0.1) (UDP)
;; WHEN: Fri Nov 21 21:59:43 GMT 2025
;; MSG SIZE rcvd: 83
Custom domain
And that's it, if you want to add your own domain just add an additional config, for example (from my previous guide), let's add domain.tld zone, /containers/bind/named.conf.d/domain.tld.conf:
zone "domain.tld" {
type master;
file "/etc/bind/db.domain.tld";
};
containers/bind/db.domain.tld: ```bind
; BIND data file for domain.tld zone
;
$TTL 86400
@ IN SOA ns.domain.tld. root.domain.tld. (
5 ; Serial
604800 ; Refresh
86400 ; Retry
2419200 ; Expire
86400 ) ; Negative Cache TTL
;
@ IN NS ns.domain.tld.
ns IN A 127.0.0.1
; hosts
containers IN A 127.0.0.1
rndc to reload server without restarting it: $ podman exec -it bind rndc -p 5953 -k /etc/bind/named.conf.d/rndc.key reload
server reload successful
5953 that we defined in config, default is 953. We are also pointing this to the key file we created previously, default location is /etc/bind/rndc.key. If it isn't obvious, you can use rndc to control bind remotely. You could also control multiple instances from single point.Once config is reloaded or server restarted, we can test it:
$ dig @127.0.0.1 -p 5353 containers.domain.tld
; <<>> DiG 9.19.21-1+b1-Debian <<>> @127.0.0.1 -p 5353 containers.domain.tld
; (1 server found)
;; global options: +cmd
;; Got answer:
;; ->>HEADER<<- opcode: QUERY, status: NOERROR, id: 42691
;; flags: qr aa rd ra; QUERY: 1, ANSWER: 1, AUTHORITY: 0, ADDITIONAL: 1
;; OPT PSEUDOSECTION:
; EDNS: version: 0, flags:; udp: 1232
; COOKIE: 86ce67ffc763a435010000006920e4f00f35acfe4368abe7 (good)
;; QUESTION SECTION:
;containers.domain.tld. IN A
;; ANSWER SECTION:
containers.domain.tld. 86400 IN A 127.0.0.1
;; Query time: 0 msec
;; SERVER: 127.0.0.1#5353(127.0.0.1) (UDP)
;; WHEN: Fri Nov 21 22:17:20 GMT 2025
;; MSG SIZE rcvd: 94